diff --git a/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts index 9c047992067..99d45ad89ee 100644 --- a/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts +++ b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts @@ -7,7 +7,7 @@ import { MediaUserLicenseEntity } from '@modules/user-license/entity'; import { mediaSourceEntityFactory, mediaUserLicenseEntityFactory } from '@modules/user-license/testing'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { type DatesToStrings, fileRecordFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { DateToString, fileRecordFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { BoardExternalReferenceType, BoardLayout, MediaBoardColors } from '../../../domain'; import { BoardNodeEntity } from '../../../repo'; import { @@ -85,7 +85,7 @@ describe('Media Board (API)', () => { const response = await studentClient.get('me'); - expect(response.body).toEqual>({ + expect(response.body).toEqual>({ id: mediaBoard.id, timestamps: { createdAt: mediaBoard.createdAt.toISOString(), @@ -205,7 +205,7 @@ describe('Media Board (API)', () => { const response = await studentClient.post(`${mediaBoard.id}/media-lines`); - expect(response.body).toEqual>({ + expect(response.body).toEqual>({ id: expect.any(String), timestamps: { createdAt: expect.any(String), diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 1a82f545a59..5c05465ad7e 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -56,6 +56,7 @@ describe(BoardNodeCopyService.name, () => { FILES_STORAGE__SERVICE_BASE_URL: '', CTL_TOOLS__PREFERRED_TOOLS_LIMIT: 10, FEATURE_PREFERRED_CTL_TOOLS_ENABLED: false, + PUBLIC_BACKEND_URL: '', }; let contextExternalToolService: DeepMocked; let copyHelperService: DeepMocked; diff --git a/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts index 5e8b9099488..911864a7622 100644 --- a/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts +++ b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts @@ -536,7 +536,7 @@ describe(OauthProviderController.name, () => { const setup = async () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); const consentListResponse: ProviderConsentSessionResponse = providerConsentSessionResponseFactory.build(); - const externalTool = externalToolEntityFactory.withOauth2Config('clientId').buildWithId(); + const externalTool = externalToolEntityFactory.withOauth2Config().buildWithId(); const pseudonym = externalToolPseudonymEntityFactory.buildWithId({ toolId: externalTool.id, userId: studentUser.id, diff --git a/apps/server/src/modules/server/admin-api-server.config.ts b/apps/server/src/modules/server/admin-api-server.config.ts index d0656f53483..4e889b24015 100644 --- a/apps/server/src/modules/server/admin-api-server.config.ts +++ b/apps/server/src/modules/server/admin-api-server.config.ts @@ -67,6 +67,7 @@ const config: AdminApiServerConfig = { TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get( 'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION' ) as string, + PUBLIC_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, }; export const adminApiServerConfig = () => config; diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index f4e5eb6ec5d..3c8f9d89708 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -323,6 +323,7 @@ const config: ServerConfig = { AES_KEY: Configuration.get('AES_KEY') as string, FEATURE_OAUTH_LOGIN: Configuration.get('FEATURE_OAUTH_LOGIN') as boolean, FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED: Configuration.get('FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED') as boolean, + PUBLIC_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/tool/common/common-tool.module.ts b/apps/server/src/modules/tool/common/common-tool.module.ts index a48b814900f..cb6b7e20ff5 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -4,7 +4,12 @@ import { CqrsModule } from '@nestjs/cqrs'; import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { SchoolModule } from '@src/modules/school'; -import { CommonToolDeleteService, CommonToolService, CommonToolValidationService } from './service'; +import { + CommonToolDeleteService, + CommonToolService, + CommonToolValidationService, + Lti11EncryptionService, +} from './service'; import { CommonToolMetadataService } from './service/common-tool-metadata.service'; @Module({ @@ -18,6 +23,7 @@ import { CommonToolMetadataService } from './service/common-tool-metadata.servic ContextExternalToolRepo, CommonToolMetadataService, CommonToolDeleteService, + Lti11EncryptionService, ], exports: [ CommonToolService, @@ -27,6 +33,7 @@ import { CommonToolMetadataService } from './service/common-tool-metadata.servic ContextExternalToolRepo, CommonToolMetadataService, CommonToolDeleteService, + Lti11EncryptionService, ], }) export class CommonToolModule {} diff --git a/apps/server/src/modules/tool/common/service/index.ts b/apps/server/src/modules/tool/common/service/index.ts index 9a6567dbbcf..43595c76ba7 100644 --- a/apps/server/src/modules/tool/common/service/index.ts +++ b/apps/server/src/modules/tool/common/service/index.ts @@ -1,3 +1,4 @@ export * from './common-tool.service'; export { CommonToolValidationService, ToolParameterTypeValidationUtil } from './validation'; export { CommonToolDeleteService } from './common-tool-delete.service'; +export { Lti11EncryptionService } from './lti11-encryption.service'; diff --git a/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts b/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts new file mode 100644 index 00000000000..ef8baadfaa3 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Authorization } from 'oauth-1.0a'; +import { Lti11EncryptionService } from './lti11-encryption.service'; + +describe(Lti11EncryptionService.name, () => { + let module: TestingModule; + let service: Lti11EncryptionService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [Lti11EncryptionService], + }).compile(); + + service = module.get(Lti11EncryptionService); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('sign', () => { + describe('when signing with OAuth1', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + }; + }; + + it('should sign the payload with OAuth1', () => { + const { mockKey, mockSecret, mockUrl, testPayload } = setup(); + + const result: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + + expect(result).toEqual({ + oauth_consumer_key: mockKey, + oauth_nonce: expect.any(String), + oauth_signature: expect.any(String), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: expect.any(Number), + oauth_version: '1.0', + ...testPayload, + }); + }); + }); + }); + + describe('verify', () => { + describe('when the OAuth1 signature is valid', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + const signedPayload: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + signedPayload, + }; + }; + + it('should return true', () => { + const { mockKey, mockSecret, mockUrl, signedPayload } = setup(); + + const result = service.verify(mockKey, mockSecret, mockUrl, signedPayload); + + expect(result).toEqual(true); + }); + }); + + describe('when the OAuth1 signature is invalid', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + const signedPayload: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + const tamperedPayload = { ...signedPayload, param1: 'test2' }; + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + tamperedPayload, + }; + }; + + it('should return false', () => { + const { mockKey, mockSecret, mockUrl, tamperedPayload } = setup(); + + const result = service.verify(mockKey, mockSecret, mockUrl, tamperedPayload); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts b/apps/server/src/modules/tool/common/service/lti11-encryption.service.ts similarity index 62% rename from apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts rename to apps/server/src/modules/tool/common/service/lti11-encryption.service.ts index 22f3bb9bee6..41e3b0c2581 100644 --- a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts +++ b/apps/server/src/modules/tool/common/service/lti11-encryption.service.ts @@ -4,7 +4,7 @@ import OAuth, { Authorization, RequestOptions } from 'oauth-1.0a'; @Injectable() export class Lti11EncryptionService { - public sign(key: string, secret: string, url: string, payload: Record): Authorization { + public sign(key: string, secret: string, url: string, payload: unknown): Authorization { const requestData: RequestOptions = { url, method: 'POST', @@ -25,4 +25,15 @@ export class Lti11EncryptionService { return authorization; } + + public verify(key: string, secret: string, url: string, payload: Authorization): boolean { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { oauth_signature, ...validationPayload } = payload; + + const authorization: Authorization = this.sign(key, secret, url, validationPayload); + + const isValid = oauth_signature === authorization.oauth_signature; + + return isValid; + } } diff --git a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts index 79e6cc63adc..9e5d4547989 100644 --- a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts +++ b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts @@ -6,9 +6,16 @@ import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; import { ContextExternalToolRule } from './authorisation/context-external-tool.rule'; -import { ContextExternalToolAuthorizableService, ContextExternalToolService, ToolReferenceService } from './service'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenMikroOrmRepo } from './repo'; +import { + ContextExternalToolAuthorizableService, + ContextExternalToolService, + LtiDeepLinkingService, + LtiDeepLinkTokenService, + ToolConfigurationStatusService, + ToolReferenceService, +} from './service'; import { ContextExternalToolValidationService } from './service/context-external-tool-validation.service'; -import { ToolConfigurationStatusService } from './service/tool-configuration-status.service'; @Module({ imports: [ @@ -26,12 +33,20 @@ import { ToolConfigurationStatusService } from './service/tool-configuration-sta ToolReferenceService, ToolConfigurationStatusService, ContextExternalToolRule, + LtiDeepLinkTokenService, + LtiDeepLinkingService, + { + provide: LTI_DEEP_LINK_TOKEN_REPO, + useClass: LtiDeepLinkTokenMikroOrmRepo, + }, ], exports: [ ContextExternalToolService, ContextExternalToolValidationService, ToolReferenceService, ToolConfigurationStatusService, + LtiDeepLinkTokenService, + LtiDeepLinkingService, ], }) export class ContextExternalToolModule {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts new file mode 100644 index 00000000000..bb71215cf33 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts @@ -0,0 +1,132 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import crypto from 'crypto-js'; +import { externalToolEntityFactory, lti11ToolConfigEntityFactory } from '../../../external-tool/testing'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; +import { ContextExternalToolEntity, ContextExternalToolType, LtiDeepLinkEmbeddable } from '../../entity'; +import { + contextExternalToolEntityFactory, + Lti11DeepLinkParamsFactory, + ltiDeepLinkTokenEntityFactory, +} from '../../testing'; +import { Lti11DeepLinkContentItemParams } from '../dto'; + +describe('ToolDeepLinkController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let orm: MikroORM; + let testApiClient: TestApiClient; + + const basePath = '/tools/context-external-tools'; + const decryptedSecret = 'secret'; + const encryptedSecret = crypto.AES.encrypt(decryptedSecret, Configuration.get('AES_KEY') as string).toString(); + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await orm.getSchemaGenerator().clearDatabase(); + }); + + describe('[POST] tools/context-external-tools/:contextExternalToolId/lti11-deep-link-callback', () => { + describe('when the lti deep linking callback is successfully', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const ltiDeepLinkToken = ltiDeepLinkTokenEntityFactory.build({ user: teacherUser }); + const course = courseFactory.buildWithId({ + teachers: [teacherUser], + }); + + const lti11Config = lti11ToolConfigEntityFactory.build({ + secret: encryptedSecret, + }); + const externalTool = externalToolEntityFactory.buildWithId({ config: lti11Config }); + const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId({ + tool: externalTool, + school: teacherUser.school, + }); + const contextExternalTool = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + }); + + const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; + const callbackUrl = `${publicBackendUrl}/v3${basePath}/${contextExternalTool.id}/lti11-deep-link-callback`; + const requestFactory = new Lti11DeepLinkParamsFactory(callbackUrl, lti11Config.key, decryptedSecret); + const postParams = requestFactory.buildRaw({ + data: ltiDeepLinkToken.state, + }); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + ltiDeepLinkToken, + course, + externalTool, + schoolExternalTool, + contextExternalTool, + ]); + em.clear(); + + const targetContent = requestFactory.build({ + data: ltiDeepLinkToken.state, + }).content_items?.['@graph'][0] as Lti11DeepLinkContentItemParams; + + return { + postParams, + contextExternalTool, + targetContent, + }; + }; + + it('should create a lti deep link with the context external tool', async () => { + const { postParams, contextExternalTool, targetContent } = await setup(); + + const response = await testApiClient + .post(`/${contextExternalTool.id}/lti11-deep-link-callback`) + .send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.text).toEqual( + 'Window can be closedThis window can be closed' + ); + const dbContextExternalTool = await em.findOneOrFail(ContextExternalToolEntity, contextExternalTool.id); + expect(dbContextExternalTool.ltiDeepLink).toMatchObject({ + mediaType: targetContent.mediaType, + title: targetContent.title, + url: targetContent.url, + text: targetContent.text, + parameters: [ + { + name: 'dl_param', + value: targetContent.custom?.dl_param, + }, + ], + availableFrom: targetContent.available?.startDatetime, + availableUntil: targetContent.available?.endDatetime, + submissionFrom: targetContent.submission?.startDatetime, + submissionUntil: targetContent.submission?.endDatetime, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts index 5803971a64b..180fcab6715 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts @@ -7,19 +7,24 @@ import { Permission } from '@shared/domain/interface'; import { cleanupCollections, courseFactory, + DateToString, fileRecordFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; import { Response } from 'supertest'; -import { CustomParameterLocation, CustomParameterScope, ToolContextType } from '../../../common/enum'; +import { CustomParameterLocation, CustomParameterScope, LtiMessageType, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; import { customParameterFactory, externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; -import { contextExternalToolConfigurationStatusResponseFactory, contextExternalToolEntityFactory } from '../../testing'; +import { + contextExternalToolConfigurationStatusResponseFactory, + contextExternalToolEntityFactory, + ltiDeepLinkEmbeddableFactory, +} from '../../testing'; import { ContextExternalToolContextParams, ToolReferenceListResponse, ToolReferenceResponse } from '../dto'; describe('ToolReferenceController (API)', () => { @@ -188,6 +193,7 @@ describe('ToolReferenceController (API)', () => { thumbnailUrl: `/api/v3/file/preview/${thumbnailFileRecord.id}/${encodeURIComponent( thumbnailFileRecord.name )}`, + isLtiDeepLinkingTool: false, }, ], }); @@ -257,34 +263,40 @@ describe('ToolReferenceController (API)', () => { ]); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const thumbnailFileRecord = fileRecordFactory.build(); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ - logoBase64: 'logoBase64', - parameters: [ - customParameterFactory.build({ - name: 'schoolMockParameter', - scope: CustomParameterScope.SCHOOL, - location: CustomParameterLocation.PATH, - }), - customParameterFactory.build({ - name: 'contextMockParameter', - scope: CustomParameterScope.CONTEXT, - location: CustomParameterLocation.PATH, - }), - ], - thumbnail: { - uploadUrl: 'https://uploadurl.com', - fileRecord: thumbnailFileRecord, - }, - }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory + .withLti11Config({ + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .buildWithId({ + logoBase64: 'logoBase64', + parameters: [ + customParameterFactory.build({ + name: 'schoolMockParameter', + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + }), + customParameterFactory.build({ + name: 'contextMockParameter', + scope: CustomParameterScope.CONTEXT, + location: CustomParameterLocation.PATH, + }), + ], + thumbnail: { + uploadUrl: 'https://uploadurl.com', + fileRecord: thumbnailFileRecord, + }, + }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, tool: externalToolEntity, }); + const ltiDeepLinkEmbeddable = ltiDeepLinkEmbeddableFactory.build(); const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, displayName: 'This is a test tool', + ltiDeepLink: ltiDeepLinkEmbeddable, }); await em.persistAndFlush([ @@ -307,6 +319,7 @@ describe('ToolReferenceController (API)', () => { contextExternalToolEntity, externalToolEntity, thumbnailFileRecord, + ltiDeepLinkEmbeddable, }; }; @@ -317,12 +330,13 @@ describe('ToolReferenceController (API)', () => { contextExternalToolEntity, externalToolEntity, thumbnailFileRecord, + ltiDeepLinkEmbeddable, } = await setup(); const response: Response = await loggedInClient.get(`context-external-tools/${contextExternalToolId}`); expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.body).toEqual({ + expect(response.body).toEqual>({ contextToolId: contextExternalToolEntity.id, description: externalToolEntity.description, displayName: contextExternalToolEntity.displayName as string, @@ -335,6 +349,16 @@ describe('ToolReferenceController (API)', () => { thumbnailUrl: `/api/v3/file/preview/${thumbnailFileRecord.id}/${encodeURIComponent( thumbnailFileRecord.name )}`, + isLtiDeepLinkingTool: true, + ltiDeepLink: { + mediaType: ltiDeepLinkEmbeddable.mediaType, + title: ltiDeepLinkEmbeddable.title, + text: ltiDeepLinkEmbeddable.text, + availableFrom: ltiDeepLinkEmbeddable.availableFrom?.toISOString(), + availableUntil: ltiDeepLinkEmbeddable.availableUntil?.toISOString(), + submissionFrom: ltiDeepLinkEmbeddable.submissionFrom?.toISOString(), + submissionUntil: ltiDeepLinkEmbeddable.submissionUntil?.toISOString(), + }, }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts index 60145a6d140..0584d6a981d 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts @@ -21,9 +21,6 @@ export class ContextExternalToolResponse { @ApiProperty({ type: [CustomParameterEntryResponse] }) parameters: CustomParameterEntryResponse[] = []; - @ApiPropertyOptional() - logoUrl?: string; - constructor(response: ContextExternalToolResponse) { this.id = response.id; this.schoolToolId = response.schoolToolId; @@ -31,6 +28,5 @@ export class ContextExternalToolResponse { this.contextType = response.contextType; this.displayName = response.displayName; this.parameters = response.parameters; - this.logoUrl = response.logoUrl; } } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts index 89922cda255..a41423e9d72 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts @@ -5,3 +5,4 @@ export * from './context-external-tool-context.params'; export * from './context-external-tool.response'; export * from './tool-reference-list.response'; export * from './tool-reference.response'; +export * from './lti11-deep-link'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts new file mode 100644 index 00000000000..1053bafe92a --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts @@ -0,0 +1,6 @@ +export { Lti11DeepLinkContentItemParams } from './lti11-deep-link-content-item.params'; +export { Lti11DeepLinkContentItemListParams } from './lti11-deep-link-content-item-list.params'; +export { Lti11ContentItemType } from './lti11-content-item-type'; +export { Lti11DeepLinkParams } from './lti11-deep-link.params'; +export { LtiDeepLinkResponse } from './lti-deep-link.response'; +export { Lti11DeepLinkContentItemDurationParams } from './lti11-deep-link-content-item-duration.params'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts new file mode 100644 index 00000000000..5962dd333fb --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LtiDeepLinkResponse { + @ApiProperty() + mediaType: string; + + @ApiPropertyOptional() + title?: string; + + @ApiPropertyOptional() + text?: string; + + @ApiPropertyOptional() + availableFrom?: Date; + + @ApiPropertyOptional() + availableUntil?: Date; + + @ApiPropertyOptional() + submissionFrom?: Date; + + @ApiPropertyOptional() + submissionUntil?: Date; + + constructor(props: LtiDeepLinkResponse) { + this.mediaType = props.mediaType; + this.title = props.title; + this.text = props.text; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts new file mode 100644 index 00000000000..5764510b9db --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts @@ -0,0 +1,5 @@ +export enum Lti11ContentItemType { + CONTENT_ITEM = 'ContentItem', + LTI_LINK_ITEM = 'LtiLinkItem', + FILE_ITEM = 'FileItem', +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts new file mode 100644 index 00000000000..102cc5eb147 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDate, IsOptional } from 'class-validator'; + +export class Lti11DeepLinkContentItemDurationParams { + @IsOptional() + @IsDate() + @ApiPropertyOptional() + startDatetime?: Date; + + @IsOptional() + @IsDate() + @ApiPropertyOptional() + endDatetime?: Date; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts new file mode 100644 index 00000000000..1ebb6b7f9d8 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsString, ValidateNested } from 'class-validator'; +import { Lti11DeepLinkContentItemParams } from './lti11-deep-link-content-item.params'; + +export class Lti11DeepLinkContentItemListParams { + @IsString() + @ApiProperty() + '@context'!: string; + + @IsArray() + @ArrayMaxSize(1) + @ValidateNested({ each: true }) + @Type(() => Lti11DeepLinkContentItemParams) + @ApiProperty() + '@graph'!: Lti11DeepLinkContentItemParams[]; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts new file mode 100644 index 00000000000..402eb2f376c --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidateRecord } from '@shared/controller'; +import { Type } from 'class-transformer'; +import { IsEnum, IsObject, IsOptional, isString, IsString, ValidateNested } from 'class-validator'; +import { Lti11ContentItemType } from './lti11-content-item-type'; +import { Lti11DeepLinkContentItemDurationParams } from './lti11-deep-link-content-item-duration.params'; + +export class Lti11DeepLinkContentItemParams { + @IsEnum(Lti11ContentItemType) + @ApiProperty() + '@type'!: Lti11ContentItemType; + + @IsString() + @ApiProperty() + mediaType!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + url?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + title?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + text?: string; + + @IsOptional() + @ValidateNested() + @Type(() => Lti11DeepLinkContentItemDurationParams) + @ApiPropertyOptional() + available?: Lti11DeepLinkContentItemDurationParams; + + @IsOptional() + @ValidateNested() + @Type(() => Lti11DeepLinkContentItemDurationParams) + @ApiPropertyOptional() + submission?: Lti11DeepLinkContentItemDurationParams; + + @IsOptional() + @IsObject() + @ValidateRecord(isString) + @Type(() => Object) + @ApiPropertyOptional() + custom?: Record; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts new file mode 100644 index 00000000000..847f151cac5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Equals, IsJSON, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Authorization } from 'oauth-1.0a'; + +export class Lti11DeepLinkParamsRaw implements Authorization { + @Equals('ContentItemSelection') + @ApiProperty() + lti_message_type!: string; + + @Equals('LTI-1p0') + @ApiProperty() + lti_version!: string; + + @IsOptional() + @IsJSON() + @ApiPropertyOptional() + content_items?: string; + + @IsString() + @ApiProperty() + data!: string; + + @Equals('1.0') + @ApiProperty() + oauth_version!: string; + + @IsString() + @ApiProperty() + oauth_nonce!: string; + + @IsNumber() + @ApiProperty() + oauth_timestamp!: number; + + @Equals('HMAC-SHA1') + @ApiProperty() + oauth_signature_method!: string; + + @IsString() + @ApiProperty() + oauth_consumer_key!: string; + + @IsString() + @ApiProperty() + oauth_signature!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + oauth_callback?: string; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts new file mode 100644 index 00000000000..047e44d91b5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StringToObject } from '@shared/controller'; +import { Type } from 'class-transformer'; +import { Equals, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Lti11DeepLinkContentItemListParams } from './lti11-deep-link-content-item-list.params'; + +export class Lti11DeepLinkParams { + @Equals('ContentItemSelection') + @ApiProperty() + lti_message_type!: string; + + @Equals('LTI-1p0') + @ApiProperty() + lti_version!: string; + + @IsOptional() + @IsObject() + @ValidateNested() + @StringToObject(Lti11DeepLinkContentItemListParams) + @Type(() => Lti11DeepLinkContentItemListParams) + @ApiPropertyOptional() + content_items?: Lti11DeepLinkContentItemListParams; + + @IsString() + @ApiProperty() + data!: string; + + @Equals('1.0') + @ApiProperty() + oauth_version!: string; + + @IsString() + @ApiProperty() + oauth_nonce!: string; + + @IsNumber() + @ApiProperty() + oauth_timestamp!: number; + + @Equals('HMAC-SHA1') + @ApiProperty() + oauth_signature_method!: string; + + @IsString() + @ApiProperty() + oauth_consumer_key!: string; + + @IsString() + @ApiProperty() + oauth_signature!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + oauth_callback?: string; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts index 9f66a020c3d..834ca4ad69a 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts @@ -1,41 +1,42 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContextExternalToolConfigurationStatusResponse } from '../../../common/controller/dto'; +import { LtiDeepLinkResponse } from './lti11-deep-link'; export class ToolReferenceResponse { - @ApiProperty({ nullable: false, required: true, description: 'The id of the tool in the context' }) + @ApiProperty({ description: 'The id of the tool in the context' }) contextToolId: string; @ApiPropertyOptional({ description: 'The description of the tool' }) description?: string; @ApiPropertyOptional({ - nullable: false, - required: false, description: 'The url of the logo which is stored in the db', }) logoUrl?: string; @ApiPropertyOptional({ - nullable: false, - required: false, description: 'The url of the thumbnail which is stored in the db', }) thumbnailUrl?: string; - @ApiProperty({ nullable: false, required: true, description: 'The display name of the tool' }) + @ApiProperty({ description: 'The display name of the tool' }) displayName: string; - @ApiProperty({ nullable: false, required: true, description: 'Whether the tool should be opened in a new tab' }) + @ApiProperty({ description: 'Whether the tool should be opened in a new tab' }) openInNewTab: boolean; @ApiProperty({ type: ContextExternalToolConfigurationStatusResponse, - nullable: false, - required: true, description: 'The status of the tool', }) status: ContextExternalToolConfigurationStatusResponse; + @ApiProperty({ description: 'Whether the tool is a lti deep linking tool' }) + isLtiDeepLinkingTool: boolean; + + @ApiPropertyOptional({ type: LtiDeepLinkResponse }) + ltiDeepLink?: LtiDeepLinkResponse; + constructor(toolReferenceResponse: ToolReferenceResponse) { this.contextToolId = toolReferenceResponse.contextToolId; this.description = toolReferenceResponse.description; @@ -44,5 +45,7 @@ export class ToolReferenceResponse { this.displayName = toolReferenceResponse.displayName; this.openInNewTab = toolReferenceResponse.openInNewTab; this.status = toolReferenceResponse.status; + this.isLtiDeepLinkingTool = toolReferenceResponse.isLtiDeepLinkingTool; + this.ltiDeepLink = toolReferenceResponse.ltiDeepLink; } } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/index.ts index 6927a20482c..1a0addf31d9 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/index.ts @@ -1,2 +1,3 @@ export * from './tool-context.controller'; export { AdminApiContextExternalToolController } from './admin-api-context-external-tool.controller'; +export { ToolDeepLinkController } from './tool-deep-link.controller'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts new file mode 100644 index 00000000000..95339a12eb9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { LtiDeepLink } from '../domain'; +import { LtiDeepLinkRequestMapper } from '../mapper'; +import { ContextExternalToolUc } from '../uc'; +import { ContextExternalToolIdParams, Lti11DeepLinkParams } from './dto'; +import { Lti11DeepLinkParamsRaw } from './dto/lti11-deep-link/lti11-deep-link-raw.params'; + +@ApiTags('Tool') +@Controller('tools/context-external-tools') +export class ToolDeepLinkController { + constructor(private readonly contextExternalToolUc: ContextExternalToolUc) {} + + @Post(':contextExternalToolId/lti11-deep-link-callback') + public async deepLink( + @Param() params: ContextExternalToolIdParams, + @Body() rawBody: Lti11DeepLinkParamsRaw, + @Body() body: Lti11DeepLinkParams + ): Promise { + const deepLink: LtiDeepLink | undefined = LtiDeepLinkRequestMapper.mapRequestToDO(body); + + await this.contextExternalToolUc.updateLtiDeepLink(params.contextExternalToolId, rawBody, body.data, deepLink); + + return 'Window can be closedThis window can be closed'; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts index 0b3a2bf7335..dbf649fc92e 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts @@ -2,6 +2,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { CustomParameterEntry } from '../../common/domain'; import { SchoolExternalToolRef } from '../../school-external-tool/domain'; import { ContextRef } from './context-ref'; +import { LtiDeepLink } from './lti-deep-link'; export interface ContextExternalToolLaunchable { id?: string; @@ -11,6 +12,8 @@ export interface ContextExternalToolLaunchable { contextRef: ContextRef; parameters: CustomParameterEntry[]; + + ltiDeepLink?: LtiDeepLink; } export interface ContextExternalToolProps extends AuthorizableObject, ContextExternalToolLaunchable { @@ -32,7 +35,19 @@ export class ContextExternalTool extends DomainObject return this.props.displayName; } + set displayName(value: string | undefined) { + this.props.displayName = value; + } + get parameters(): CustomParameterEntry[] { return this.props.parameters; } + + get ltiDeepLink(): LtiDeepLink | undefined { + return this.props.ltiDeepLink; + } + + set ltiDeepLink(value: LtiDeepLink | undefined) { + this.props.ltiDeepLink = value; + } } diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts index 75d3370f475..ca3eeb7e8e4 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts @@ -1 +1,5 @@ export { RestrictedContextMismatchLoggableException } from './restricted-context-mismatch-loggabble'; +export { LtiDeepLinkTokenMissingLoggableException } from './lti-deep-link-token-missing.loggable-exception'; +export { LtiMessageTypeNotImplementedLoggableException } from './lti-message-type-not-implemented.loggable-exception'; +export { InvalidToolTypeLoggableException } from './invalid-tool-type.loggable-exception'; +export { InvalidOauthSignatureLoggableException } from './invalid-oauth-signature.loggable-exception'; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts new file mode 100644 index 00000000000..2374ee52909 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts @@ -0,0 +1,25 @@ +import { InvalidOauthSignatureLoggableException } from './invalid-oauth-signature.loggable-exception'; + +describe(InvalidOauthSignatureLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const loggable = new InvalidOauthSignatureLoggableException(); + + return { + loggable, + }; + }; + + it('should return a loggable message', () => { + const { loggable } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_OAUTH_SIGNATURE', + message: 'The oauth signature is invalid.', + stack: loggable.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts new file mode 100644 index 00000000000..0a4966999e6 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts @@ -0,0 +1,14 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class InvalidOauthSignatureLoggableException extends BadRequestException implements Loggable { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'INVALID_OAUTH_SIGNATURE', + message: 'The oauth signature is invalid.', + stack: this.stack, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts new file mode 100644 index 00000000000..9183c772095 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { ToolConfigType } from '../../../common/enum'; +import { InvalidToolTypeLoggableException } from './invalid-tool-type.loggable-exception'; + +describe(InvalidToolTypeLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const expected = ToolConfigType.LTI11; + const received = ToolConfigType.OAUTH2; + + const loggable = new InvalidToolTypeLoggableException(expected, received); + + return { + loggable, + expected, + received, + }; + }; + + it('should return a loggable message', () => { + const { loggable, expected, received } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_TOOL_TYPE', + message: 'The external tool has the wrong tool type.', + stack: loggable.stack, + data: { + expected, + received, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts new file mode 100644 index 00000000000..7974634fde2 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts @@ -0,0 +1,23 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ToolConfigType } from '../../../common/enum'; + +export class InvalidToolTypeLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly expected: ToolConfigType, private readonly received: ToolConfigType) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'INVALID_TOOL_TYPE', + message: 'The external tool has the wrong tool type.', + stack: this.stack, + data: { + expected: this.expected, + received: this.received, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..0822150fe0c --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts @@ -0,0 +1,36 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UUID } from 'bson'; +import { LtiDeepLinkTokenMissingLoggableException } from './lti-deep-link-token-missing.loggable-exception'; + +describe(LtiDeepLinkTokenMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const state = new UUID().toString(); + const contextExternalToolId = new ObjectId().toHexString(); + + const loggable = new LtiDeepLinkTokenMissingLoggableException(state, contextExternalToolId); + + return { + loggable, + state, + contextExternalToolId, + }; + }; + + it('should return a loggable message', () => { + const { loggable, state, contextExternalToolId } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'UNAUTHORIZED_EXCEPTION', + message: 'Unable to find lti deep link token for this state. It might have expired.', + stack: loggable.stack, + data: { + state, + contextExternalToolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts new file mode 100644 index 00000000000..8fe180a21b6 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts @@ -0,0 +1,23 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class LtiDeepLinkTokenMissingLoggableException extends UnauthorizedException implements Loggable { + constructor(private readonly state: string, private readonly contextExternalToolId: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'UNAUTHORIZED_EXCEPTION', + message: 'Unable to find lti deep link token for this state. It might have expired.', + stack: this.stack, + data: { + state: this.state, + contextExternalToolId: this.contextExternalToolId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts new file mode 100644 index 00000000000..1d612307fc9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { LtiMessageTypeNotImplementedLoggableException } from './lti-message-type-not-implemented.loggable-exception'; + +describe(LtiMessageTypeNotImplementedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const unknownMessageType = 'unknownMessageType'; + + const loggable = new LtiMessageTypeNotImplementedLoggableException(unknownMessageType); + + return { + loggable, + unknownMessageType, + }; + }; + + it('should return a loggable message', () => { + const { loggable, unknownMessageType } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'LTI_MESSAGE_TYPE_NOT_IMPLEMENTED', + message: 'The lti message type is not implemented.', + stack: loggable.stack, + data: { + lti_message_type: unknownMessageType, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts new file mode 100644 index 00000000000..31202563978 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts @@ -0,0 +1,21 @@ +import { NotImplementedException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class LtiMessageTypeNotImplementedLoggableException extends NotImplementedException implements Loggable { + constructor(private readonly ltiMessageType: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'LTI_MESSAGE_TYPE_NOT_IMPLEMENTED', + message: 'The lti message type is not implemented.', + stack: this.stack, + data: { + lti_message_type: this.ltiMessageType, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index bb51be61682..e0b8c357724 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -2,4 +2,6 @@ export * from './context-external-tool.do'; export * from './context-ref'; export * from './tool-reference'; export * from './event'; -export { RestrictedContextMismatchLoggableException } from './error'; +export * from './error'; +export { LtiDeepLink } from './lti-deep-link'; +export { LtiDeepLinkToken, LtiDeepLinkTokenProps } from './lti-deep-link-token'; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts new file mode 100644 index 00000000000..3ad685a9f36 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts @@ -0,0 +1,24 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface LtiDeepLinkTokenProps extends AuthorizableObject { + state: string; + + userId: EntityId; + + expiresAt: Date; +} + +export class LtiDeepLinkToken extends DomainObject { + get state(): string { + return this.props.state; + } + + get userId(): EntityId { + return this.props.userId; + } + + get expiresAt(): Date { + return this.props.expiresAt; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts new file mode 100644 index 00000000000..f2695e92391 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts @@ -0,0 +1,33 @@ +import { CustomParameterEntry } from '../../common/domain'; + +export class LtiDeepLink { + mediaType: string; + + url?: string; + + title?: string; + + text?: string; + + parameters: CustomParameterEntry[]; + + availableFrom?: Date; + + availableUntil?: Date; + + submissionFrom?: Date; + + submissionUntil?: Date; + + constructor(props: LtiDeepLink) { + this.mediaType = props.mediaType; + this.url = props.url; + this.title = props.title; + this.text = props.text; + this.parameters = props.parameters; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts b/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts index 4db5fe8f120..76e857c3ece 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts @@ -1,4 +1,5 @@ import { ContextExternalToolConfigurationStatus } from '../../common/domain'; +import { LtiDeepLink } from './lti-deep-link'; export class ToolReference { contextToolId: string; @@ -15,6 +16,10 @@ export class ToolReference { status: ContextExternalToolConfigurationStatus; + isLtiDeepLinkingTool: boolean; + + ltiDeepLink?: LtiDeepLink; + constructor(toolReference: ToolReference) { this.contextToolId = toolReference.contextToolId; this.description = toolReference.description; @@ -23,5 +28,7 @@ export class ToolReference { this.displayName = toolReference.displayName; this.openInNewTab = toolReference.openInNewTab; this.status = toolReference.status; + this.isLtiDeepLinkingTool = toolReference.isLtiDeepLinkingTool; + this.ltiDeepLink = toolReference.ltiDeepLink; } } 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 17e415eb7d3..98581c15a41 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 @@ -5,6 +5,7 @@ import { EntityId } from '@shared/domain/types'; import { CustomParameterEntryEntity } from '../../common/entity'; import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; import { ContextExternalToolType } from './context-external-tool-type.enum'; +import { LtiDeepLinkEmbeddable } from './lti-deep-link.embeddable'; export interface ContextExternalToolEntityProps { id?: EntityId; @@ -18,6 +19,8 @@ export interface ContextExternalToolEntityProps { displayName?: string; parameters?: CustomParameterEntryEntity[]; + + ltiDeepLink?: LtiDeepLinkEmbeddable; } @Entity({ tableName: 'context-external-tools' }) @@ -37,6 +40,9 @@ export class ContextExternalToolEntity extends BaseEntityWithTimestamps { @Embedded(() => CustomParameterEntryEntity, { array: true }) parameters: CustomParameterEntryEntity[]; + @Embedded(() => LtiDeepLinkEmbeddable, { nullable: true, object: true }) + ltiDeepLink?: LtiDeepLinkEmbeddable; + constructor(props: ContextExternalToolEntityProps) { super(); if (props.id) { @@ -47,5 +53,6 @@ export class ContextExternalToolEntity extends BaseEntityWithTimestamps { this.contextType = props.contextType; this.displayName = props.displayName; this.parameters = props.parameters ?? []; + this.ltiDeepLink = props.ltiDeepLink; } } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/index.ts b/apps/server/src/modules/tool/context-external-tool/entity/index.ts index cc6609164e4..9399a382d9c 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/index.ts @@ -1,2 +1,4 @@ export * from './context-external-tool.entity'; export * from './context-external-tool-type.enum'; +export { LtiDeepLinkEmbeddable } from './lti-deep-link.embeddable'; +export { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from './lti-deep-link-token.entity'; diff --git a/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts new file mode 100644 index 00000000000..ebc8396a7ca --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts @@ -0,0 +1,38 @@ +import { Entity, Index, ManyToOne, Property, Unique } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { User } from '@shared/domain/entity/user.entity'; +import { EntityId } from '@shared/domain/types'; + +export interface LtiDeepLinkTokenEntityProps { + id?: EntityId; + + state: string; + + user: User; + + expiresAt: Date; +} + +@Entity({ tableName: 'lti-deep-link-token' }) +export class LtiDeepLinkTokenEntity extends BaseEntityWithTimestamps { + @Unique() + @Property() + state: string; + + @ManyToOne(() => User) + user: User; + + @Index({ options: { expireAfterSeconds: 0 } }) + @Property() + expiresAt: Date; + + constructor(props: LtiDeepLinkTokenEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.state = props.state; + this.user = props.user; + this.expiresAt = props.expiresAt; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts new file mode 100644 index 00000000000..31889a2aee1 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts @@ -0,0 +1,44 @@ +import { Embeddable, Embedded, Property } from '@mikro-orm/core'; +import { CustomParameterEntryEntity } from '../../common/entity'; + +@Embeddable() +export class LtiDeepLinkEmbeddable { + @Property() + mediaType: string; + + @Property({ nullable: true }) + url?: string; + + @Property({ nullable: true }) + title?: string; + + @Property({ nullable: true }) + text?: string; + + @Embedded(() => CustomParameterEntryEntity, { array: true }) + parameters: CustomParameterEntryEntity[]; + + @Property({ nullable: true }) + availableFrom?: Date; + + @Property({ nullable: true }) + availableUntil?: Date; + + @Property({ nullable: true }) + submissionFrom?: Date; + + @Property({ nullable: true }) + submissionUntil?: Date; + + constructor(props: LtiDeepLinkEmbeddable) { + this.mediaType = props.mediaType; + this.title = props.title; + this.url = props.url; + this.parameters = props.parameters; + this.text = props.text; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts index 0da038d6124..b9d0d5fa18d 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts @@ -1,6 +1,6 @@ -import { ToolStatusResponseMapper } from '../../common/mapper/tool-status-response.mapper'; +import { ToolStatusResponseMapper } from '../../common/mapper'; import { CustomParameterEntryParam, CustomParameterEntryResponse } from '../../school-external-tool/controller/dto'; -import { ContextExternalToolResponse, ToolReferenceResponse } from '../controller/dto'; +import { ContextExternalToolResponse, LtiDeepLinkResponse, ToolReferenceResponse } from '../controller/dto'; import { ContextExternalTool, ToolReference } from '../domain'; export class ContextExternalToolResponseMapper { @@ -43,6 +43,19 @@ export class ContextExternalToolResponseMapper { } static mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { + const { ltiDeepLink } = toolReference; + const ltiDeepLinkResponse: LtiDeepLinkResponse | undefined = ltiDeepLink + ? new LtiDeepLinkResponse({ + mediaType: ltiDeepLink.mediaType, + title: ltiDeepLink.title, + text: ltiDeepLink.text, + availableFrom: ltiDeepLink.availableFrom, + availableUntil: ltiDeepLink.availableUntil, + submissionFrom: ltiDeepLink.submissionFrom, + submissionUntil: ltiDeepLink.submissionUntil, + }) + : undefined; + const response = new ToolReferenceResponse({ contextToolId: toolReference.contextToolId, description: toolReference.description, @@ -51,6 +64,8 @@ export class ContextExternalToolResponseMapper { thumbnailUrl: toolReference.thumbnailUrl, openInNewTab: toolReference.openInNewTab, status: ToolStatusResponseMapper.mapToResponse(toolReference.status), + isLtiDeepLinkingTool: toolReference.isLtiDeepLinkingTool, + ltiDeepLink: ltiDeepLinkResponse, }); return response; diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts index 427f02a713a..31c1924280e 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts @@ -1,3 +1,4 @@ export * from './context-external-tool-request.mapper'; export * from './context-external-tool-response.mapper'; export * from './tool-reference.mapper'; +export { LtiDeepLinkRequestMapper } from './lti-deep-link-request.mapper'; diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts new file mode 100644 index 00000000000..92ec4342528 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts @@ -0,0 +1,32 @@ +import { CustomParameterEntry } from '../../common/domain'; +import { Lti11DeepLinkContentItemParams, Lti11DeepLinkParams } from '../controller/dto'; +import { LtiDeepLink } from '../domain'; + +export class LtiDeepLinkRequestMapper { + public static mapRequestToDO(params: Lti11DeepLinkParams): LtiDeepLink | undefined { + const contentItem: Lti11DeepLinkContentItemParams | undefined = params.content_items?.['@graph'][0]; + + let parameters: CustomParameterEntry[] = []; + if (contentItem?.custom) { + parameters = Object.entries(contentItem.custom).map( + ([key, value]: [string, string]) => new CustomParameterEntry({ name: key, value }) + ); + } + + const deepLink: LtiDeepLink | undefined = contentItem + ? new LtiDeepLink({ + mediaType: contentItem.mediaType, + title: contentItem.title, + text: contentItem.text, + url: contentItem.url, + parameters, + availableFrom: contentItem.available?.startDatetime, + availableUntil: contentItem.available?.endDatetime, + submissionFrom: contentItem.submission?.startDatetime, + submissionUntil: contentItem.submission?.endDatetime, + }) + : undefined; + + return deepLink; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts index b75c6759dda..3d3aeaf2cf3 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts @@ -16,6 +16,8 @@ export class ToolReferenceMapper { displayName: contextExternalTool.displayName ?? externalTool.name, status, openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: externalTool.isLtiDeepLinkingTool(), + ltiDeepLink: contextExternalTool.ltiDeepLink, }); return toolReference; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/index.ts new file mode 100644 index 00000000000..a3f2b765f9f --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/index.ts @@ -0,0 +1,2 @@ +export { LtiDeepLinkTokenRepo, LTI_DEEP_LINK_TOKEN_REPO } from './lti-deep-link-token.repo.interface'; +export { LtiDeepLinkTokenMikroOrmRepo } from './mikro-orm'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts b/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts new file mode 100644 index 00000000000..6f2e6356fdd --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts @@ -0,0 +1,11 @@ +import { LtiDeepLinkToken } from '../domain'; + +export interface LtiDeepLinkTokenRepo { + save(domainObject: LtiDeepLinkToken): Promise; + + delete(domainObject: LtiDeepLinkToken): Promise; + + findByState(state: string): Promise; +} + +export const LTI_DEEP_LINK_TOKEN_REPO = 'LTI_DEEP_LINK_TOKEN_REPO'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts new file mode 100644 index 00000000000..61fc9fc4ab9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts @@ -0,0 +1 @@ +export { LtiDeepLinkTokenMikroOrmRepo } from './lti-deep-link-token.repo'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts new file mode 100644 index 00000000000..7bef843ce8b --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts @@ -0,0 +1,133 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { LtiDeepLinkToken } from '../../domain'; +import { LtiDeepLinkTokenEntity } from '../../entity'; +import { ltiDeepLinkTokenEntityFactory, ltiDeepLinkTokenFactory } from '../../testing'; +import { LTI_DEEP_LINK_TOKEN_REPO } from '../lti-deep-link-token.repo.interface'; +import { LtiDeepLinkTokenMikroOrmRepo } from './lti-deep-link-token.repo'; +import { LtiDeepLinkTokenEntityMapper } from './mapper'; + +describe(LtiDeepLinkTokenMikroOrmRepo.name, () => { + let module: TestingModule; + let repo: LtiDeepLinkTokenMikroOrmRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [{ provide: LTI_DEEP_LINK_TOKEN_REPO, useClass: LtiDeepLinkTokenMikroOrmRepo }], + }).compile(); + + repo = module.get(LTI_DEEP_LINK_TOKEN_REPO); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('save', () => { + describe('when a new object is provided', () => { + const setup = () => { + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + return { + ltiDeepLinkToken, + }; + }; + + it('should create a new entity', async () => { + const { ltiDeepLinkToken } = setup(); + + await repo.save(ltiDeepLinkToken); + + await expect(em.findOneOrFail(LtiDeepLinkTokenEntity, ltiDeepLinkToken.id)).resolves.toBeDefined(); + }); + + it('should return the object', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await repo.save(ltiDeepLinkToken); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + + describe('when an entity with the id exists', () => { + const setup = async () => { + const ltiDeepLinkTokenEntity = ltiDeepLinkTokenEntityFactory.build({ + state: 'token1', + }); + + await em.persistAndFlush(ltiDeepLinkTokenEntity); + em.clear(); + + const ltiDeepLinkToken = new LtiDeepLinkToken({ + ...LtiDeepLinkTokenEntityMapper.mapEntityToDo(ltiDeepLinkTokenEntity).getProps(), + state: 'token2', + }); + + return { + ltiDeepLinkToken, + }; + }; + + it('should update the entity', async () => { + const { ltiDeepLinkToken } = await setup(); + + await repo.save(ltiDeepLinkToken); + + await expect(em.findOneOrFail(LtiDeepLinkTokenEntity, ltiDeepLinkToken.id)).resolves.toEqual( + expect.objectContaining({ state: 'token2' }) + ); + }); + + it('should return the object', async () => { + const { ltiDeepLinkToken } = await setup(); + + const result = await repo.save(ltiDeepLinkToken); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); + + describe('findByState', () => { + describe('when a state without a saved token is provided', () => { + it('should return null', async () => { + const result = await repo.findByState('state'); + + expect(result).toBeNull(); + }); + }); + + describe('when a state with a saved token is provided', () => { + const setup = async () => { + const ltiDeepLinkTokenEntity = ltiDeepLinkTokenEntityFactory.buildWithId(); + + await em.persistAndFlush([ltiDeepLinkTokenEntity]); + em.clear(); + + const ltiDeepLinkToken = LtiDeepLinkTokenEntityMapper.mapEntityToDo(ltiDeepLinkTokenEntity); + + return { + ltiDeepLinkToken, + }; + }; + + it('should return the latest session token domain object', async () => { + const { ltiDeepLinkToken } = await setup(); + + const result = await repo.findByState(ltiDeepLinkToken.state); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts new file mode 100644 index 00000000000..ff74a88b4f4 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts @@ -0,0 +1,33 @@ +import { EntityData, EntityName } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { LtiDeepLinkToken } from '../../domain'; +import { LtiDeepLinkTokenEntity } from '../../entity'; +import { LtiDeepLinkTokenRepo } from '../lti-deep-link-token.repo.interface'; +import { LtiDeepLinkTokenEntityMapper } from './mapper'; + +@Injectable() +export class LtiDeepLinkTokenMikroOrmRepo + extends BaseDomainObjectRepo + implements LtiDeepLinkTokenRepo +{ + protected get entityName(): EntityName { + return LtiDeepLinkTokenEntity; + } + + protected mapDOToEntityProperties(entityDO: LtiDeepLinkToken): EntityData { + return LtiDeepLinkTokenEntityMapper.mapDOToEntityProperties(entityDO, this.em); + } + + async findByState(state: string): Promise { + const sessionTokenEntity: LtiDeepLinkTokenEntity | null = await this.em.findOne(this.entityName, { state }); + + if (!sessionTokenEntity) { + return null; + } + + const sessionToken: LtiDeepLinkToken = LtiDeepLinkTokenEntityMapper.mapEntityToDo(sessionTokenEntity); + + return sessionToken; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts new file mode 100644 index 00000000000..aac11e9acb9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts @@ -0,0 +1 @@ +export { LtiDeepLinkTokenEntityMapper } from './lti-deep-link-token-entity.mapper'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts new file mode 100644 index 00000000000..9a9d54cb4ab --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts @@ -0,0 +1,31 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { User } from '@shared/domain/entity'; +import { LtiDeepLinkToken } from '../../../domain'; +import { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from '../../../entity'; + +export class LtiDeepLinkTokenEntityMapper { + public static mapDOToEntityProperties( + domainObject: LtiDeepLinkToken, + em: EntityManager + ): LtiDeepLinkTokenEntityProps { + const entityProps: LtiDeepLinkTokenEntityProps = { + id: domainObject.id, + state: domainObject.state, + user: em.getReference(User, domainObject.userId), + expiresAt: domainObject.expiresAt, + }; + + return entityProps; + } + + public static mapEntityToDo(entity: LtiDeepLinkTokenEntity): LtiDeepLinkToken { + const domainObject = new LtiDeepLinkToken({ + id: entity.id, + userId: entity.user.id, + state: entity.state, + expiresAt: entity.expiresAt, + }); + + return domainObject; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts index 0f5a7f19601..a73bfe44a20 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts @@ -1,23 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ContextExternalToolNameAlreadyExistsLoggableException } from '@modules/tool/common/domain'; -import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; import { CommonToolValidationService } from '../../common/service'; -import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalTool } from '../domain'; +import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { contextExternalToolFactory } from '../testing'; import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; -import { ContextExternalToolService } from './context-external-tool.service'; describe('ContextExternalToolValidationService', () => { let module: TestingModule; let service: ContextExternalToolValidationService; - let contextExternalToolService: DeepMocked; let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let commonToolValidationService: DeepMocked; @@ -26,10 +21,6 @@ describe('ContextExternalToolValidationService', () => { module = await Test.createTestingModule({ providers: [ ContextExternalToolValidationService, - { - provide: ContextExternalToolService, - useValue: createMock(), - }, { provide: ExternalToolService, useValue: createMock(), @@ -46,7 +37,6 @@ describe('ContextExternalToolValidationService', () => { }).compile(); service = module.get(ContextExternalToolValidationService); - contextExternalToolService = module.get(ContextExternalToolService); externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); commonToolValidationService = module.get(CommonToolValidationService); @@ -61,17 +51,21 @@ describe('ContextExternalToolValidationService', () => { }); describe('validate', () => { - describe('when no tool with the name exists in the context', () => { + describe('when a tool is valid', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - externalToolService.findById.mockResolvedValue(externalTool); - - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + }, displayName: 'Tool 1', }); - contextExternalToolService.findContextExternalTools.mockResolvedValue([ - contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), - ]); + + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); commonToolValidationService.validateParameters.mockReturnValue([]); return { @@ -80,17 +74,6 @@ describe('ContextExternalToolValidationService', () => { }; }; - it('should call contextExternalToolService.findContextExternalTools', async () => { - const { contextExternalTool } = setup(); - - await service.validate(contextExternalTool); - - expect(contextExternalToolService.findContextExternalTools).toBeCalledWith({ - schoolToolRef: contextExternalTool.schoolToolRef, - context: contextExternalTool.contextRef, - }); - }); - it('should call schoolExternalToolService.getSchoolExternalToolById', async () => { const { contextExternalTool } = setup(); @@ -107,83 +90,32 @@ describe('ContextExternalToolValidationService', () => { expect(commonToolValidationService.validateParameters).toBeCalledWith(externalTool, contextExternalTool); }); - it('should not throw UnprocessableEntityException', async () => { + it('should not throw', async () => { const { contextExternalTool } = setup(); const func = () => service.validate(contextExternalTool); - await expect(func()).resolves.not.toThrowError(UnprocessableEntityException); - }); - }); - - describe('when a tool with the same name already exists in that context', () => { - describe('when the displayName is undefined', () => { - const setup = () => { - const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: undefined }); - const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: undefined }); - - contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); - - return { - contextExternalTool1, - }; - }; - - it('should throw ValidationError', async () => { - const { contextExternalTool1 } = setup(); - - const func = () => service.validate(contextExternalTool1); - - await expect(func()).rejects.toThrowError( - new ContextExternalToolNameAlreadyExistsLoggableException( - contextExternalTool1.id, - contextExternalTool1.displayName - ) - ); - }); - }); - - describe('when the displayName is the same', () => { - const setup = () => { - const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); - const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); - - contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); - - return { - contextExternalTool1, - }; - }; - - it('should throw ValidationError', async () => { - const { contextExternalTool1 } = setup(); - - const func = () => service.validate(contextExternalTool1); - - await expect(func()).rejects.toThrowError( - new ContextExternalToolNameAlreadyExistsLoggableException( - contextExternalTool1.id, - contextExternalTool1.displayName - ) - ); - }); + await expect(func()).resolves.not.toThrow(); }); }); describe('when the parameter validation fails', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + }, displayName: 'Tool 1', }); const error: ValidationError = new ValidationError(''); + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); - contextExternalToolService.findContextExternalTools.mockResolvedValue([ - contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), - ]); commonToolValidationService.validateParameters.mockReturnValue([error]); return { diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index 97bb49e1882..f83c6d2c597 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -1,26 +1,21 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ContextExternalToolNameAlreadyExistsLoggableException } from '@modules/tool/common/domain/error/context-external-tool-name-already-exists.loggable-exception'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool } from '../domain'; -import { ContextExternalToolService } from './context-external-tool.service'; @Injectable() export class ContextExternalToolValidationService { constructor( - private readonly contextExternalToolService: ContextExternalToolService, private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly commonToolValidationService: CommonToolValidationService ) {} async validate(contextExternalTool: ContextExternalTool): Promise { - await this.checkDuplicateUsesInContext(contextExternalTool); - const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); @@ -36,21 +31,4 @@ export class ContextExternalToolValidationService { throw errors[0]; } } - - private async checkDuplicateUsesInContext(contextExternalTool: ContextExternalTool) { - let duplicate: ContextExternalTool[] = await this.contextExternalToolService.findContextExternalTools({ - schoolToolRef: contextExternalTool.schoolToolRef, - context: contextExternalTool.contextRef, - }); - - // Only leave tools that are not the currently handled tool itself (for updates) or ones with the same name - duplicate = duplicate.filter( - (duplicateTool) => - duplicateTool.id !== contextExternalTool.id && duplicateTool.displayName === contextExternalTool.displayName - ); - - if (duplicate.length > 0) { - throw new ContextExternalToolNameAlreadyExistsLoggableException(duplicate[0].id, duplicate[0].displayName); - } - } } diff --git a/apps/server/src/modules/tool/context-external-tool/service/index.ts b/apps/server/src/modules/tool/context-external-tool/service/index.ts index ca5dd69b3f3..d6c56cef556 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/index.ts @@ -2,3 +2,5 @@ export * from './context-external-tool.service'; export * from './context-external-tool-authorizable.service'; export * from './tool-reference.service'; export { ToolConfigurationStatusService } from './tool-configuration-status.service'; +export { LtiDeepLinkingService } from './lti-deep-linking.service'; +export { LtiDeepLinkTokenService } from './lti-deep-link-token.service'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts new file mode 100644 index 00000000000..d217bad54cb --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts @@ -0,0 +1,108 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkToken } from '../domain'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenRepo } from '../repo'; +import { ltiDeepLinkTokenFactory } from '../testing'; +import { LtiDeepLinkTokenService } from './lti-deep-link-token.service'; + +describe(LtiDeepLinkTokenService.name, () => { + let module: TestingModule; + let service: LtiDeepLinkTokenService; + + let ltiDeepLinkTokenRepo: DeepMocked; + let configService: DeepMocked>; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LtiDeepLinkTokenService, + { + provide: LTI_DEEP_LINK_TOKEN_REPO, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(LtiDeepLinkTokenService); + ltiDeepLinkTokenRepo = module.get(LTI_DEEP_LINK_TOKEN_REPO); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('generateToken', () => { + describe('when generating a token', () => { + const setup = () => { + jest.useFakeTimers().setSystemTime(new Date('2024-01-01')); + const tokenDuration = 2000; + + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ + expiresAt: new Date(Date.now() + tokenDuration), + }); + + configService.get.mockReturnValueOnce(tokenDuration); + ltiDeepLinkTokenRepo.save.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + ltiDeepLinkToken, + }; + }; + + it('should save a token', async () => { + const { ltiDeepLinkToken } = setup(); + + await service.generateToken(ltiDeepLinkToken.userId); + + expect(ltiDeepLinkTokenRepo.save).toHaveBeenCalledWith( + new LtiDeepLinkToken({ + ...ltiDeepLinkToken.getProps(), + id: expect.any(String), + state: expect.any(String), + }) + ); + }); + + it('should return a token', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await service.generateToken(ltiDeepLinkToken.userId); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); + + describe('findByState', () => { + describe('when searching a token by state', () => { + const setup = () => { + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + ltiDeepLinkTokenRepo.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + ltiDeepLinkToken, + }; + }; + + it('should return the token', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await service.findByState(ltiDeepLinkToken.state); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts new file mode 100644 index 00000000000..81cc64e5ab7 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts @@ -0,0 +1,37 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EntityId } from '@shared/domain/types'; +import { UUID } from 'bson'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkToken } from '../domain'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenRepo } from '../repo'; + +@Injectable() +export class LtiDeepLinkTokenService { + constructor( + @Inject(LTI_DEEP_LINK_TOKEN_REPO) private readonly ltiDeepLinkTokenRepo: LtiDeepLinkTokenRepo, + private readonly configService: ConfigService + ) {} + + public async generateToken(userId: EntityId): Promise { + const tokenDurationMs = this.configService.get('CTL_TOOLS_RELOAD_TIME_MS'); + + const ltiDeepLinkToken: LtiDeepLinkToken = await this.ltiDeepLinkTokenRepo.save( + new LtiDeepLinkToken({ + id: new ObjectId().toHexString(), + userId, + state: new UUID().toString(), + expiresAt: new Date(Date.now() + tokenDurationMs), + }) + ); + + return ltiDeepLinkToken; + } + + public async findByState(state: string): Promise { + const ltiDeepLinkToken: LtiDeepLinkToken | null = await this.ltiDeepLinkTokenRepo.findByState(state); + + return ltiDeepLinkToken; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts new file mode 100644 index 00000000000..e0f2348de72 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkingService } from './lti-deep-linking.service'; + +describe(LtiDeepLinkingService.name, () => { + let module: TestingModule; + let service: LtiDeepLinkingService; + + let configService: DeepMocked>; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LtiDeepLinkingService, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(LtiDeepLinkingService); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getCallbackUrl', () => { + describe('when requesting the callback url for lti 1.1 deep linking', () => { + const setup = () => { + const contextExternalToolId = new ObjectId().toHexString(); + const publicBackendUrl = 'https://test.com/api'; + + configService.get.mockReturnValueOnce(publicBackendUrl); + + return { + contextExternalToolId, + publicBackendUrl, + }; + }; + + it('should return the callback url', () => { + const { contextExternalToolId, publicBackendUrl } = setup(); + + const result = service.getCallbackUrl(contextExternalToolId); + + expect(result).toEqual( + `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalToolId}/lti11-deep-link-callback` + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts new file mode 100644 index 00000000000..4404d5c6204 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EntityId } from '@shared/domain/types'; +import { ToolConfig } from '../../tool-config'; + +@Injectable() +export class LtiDeepLinkingService { + constructor(private readonly configService: ConfigService) {} + + public getCallbackUrl(contextExternalToolId: EntityId): string { + const publicBackendUrl: string = this.configService.get('PUBLIC_BACKEND_URL'); + + const callbackUrl = new URL( + `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalToolId}/lti11-deep-link-callback` + ); + + return callbackUrl.toString(); + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts index e62888c22d8..5b081335e20 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts @@ -1,12 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { LtiMessageType } from '../../common/enum'; import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; -import { externalToolFactory, toolConfigurationStatusFactory } from '../../external-tool/testing'; +import { externalToolFactory, fileRecordRefFactory, toolConfigurationStatusFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolReference } from '../domain'; -import { contextExternalToolFactory } from '../testing'; +import { contextExternalToolFactory, ltiDeepLinkFactory } from '../testing'; import { ContextExternalToolService } from './context-external-tool.service'; import { ToolConfigurationStatusService } from './tool-configuration-status.service'; import { ToolReferenceService } from './tool-reference.service'; @@ -69,13 +70,24 @@ describe('ToolReferenceService', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const contextExternalToolId = new ObjectId().toHexString(); - const externalTool = externalToolFactory.buildWithId(); + const externalTool = externalToolFactory + .withLti11Config({ + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .buildWithId({ + thumbnail: fileRecordRefFactory.build(), + }); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id, }); const contextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id) - .buildWithId(undefined, contextExternalToolId); + .buildWithId( + { + ltiDeepLink: ltiDeepLinkFactory.build(), + }, + contextExternalToolId + ); const logoUrl = 'logoUrl'; contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); @@ -135,6 +147,9 @@ describe('ToolReferenceService', () => { }), contextToolId: contextExternalToolId, description: externalTool.description, + thumbnailUrl: externalTool.thumbnail?.getPreviewUrl(), + isLtiDeepLinkingTool: true, + ltiDeepLink: contextExternalTool.ltiDeepLink, }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/index.ts b/apps/server/src/modules/tool/context-external-tool/testing/index.ts index fe6d6040ded..a4ee8fd3198 100644 --- a/apps/server/src/modules/tool/context-external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/index.ts @@ -1,3 +1,8 @@ export { contextExternalToolEntityFactory } from './context-external-tool-entity.factory'; export { contextExternalToolFactory } from './context-external-tool.factory'; export { contextExternalToolConfigurationStatusResponseFactory } from './context-external-tool-configuration-status-response.factory'; +export { ltiDeepLinkFactory } from './lti-deep-link.factory'; +export { ltiDeepLinkTokenFactory } from './lti-deep-link-token.factory'; +export { ltiDeepLinkTokenEntityFactory } from './lti-deep-link-token-entity.factory'; +export { Lti11DeepLinkParamsFactory } from './lti11-deep-link-params.factory'; +export { ltiDeepLinkEmbeddableFactory } from './lti-deep-link-embeddable.factory'; diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts new file mode 100644 index 00000000000..cdffaa33480 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts @@ -0,0 +1,20 @@ +import { BaseFactory } from '@shared/testing'; +import { CustomParameterEntry } from '../../common/domain'; +import { LtiDeepLinkEmbeddable } from '../entity'; + +export const ltiDeepLinkEmbeddableFactory = BaseFactory.define( + LtiDeepLinkEmbeddable, + ({ sequence }) => { + return { + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: `Deep Link Content ${sequence}`, + url: 'https://lti.deep.link', + text: 'Deep link description', + parameters: [new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' })], + availableFrom: new Date(), + availableUntil: new Date(), + submissionFrom: new Date(), + submissionUntil: new Date(), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts new file mode 100644 index 00000000000..2261d29ca68 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory, userFactory } from '@shared/testing'; +import { UUID } from 'bson'; +import { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from '../entity'; + +export const ltiDeepLinkTokenEntityFactory = BaseFactory.define( + LtiDeepLinkTokenEntity, + () => { + const expiryTimestampMs = Date.now() + 1000000; + + return { + id: new ObjectId().toHexString(), + state: new UUID().toString(), + user: userFactory.buildWithId(), + expiresAt: new Date(expiryTimestampMs), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts new file mode 100644 index 00000000000..8df64cfc115 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { UUID } from 'bson'; +import { LtiDeepLinkToken, LtiDeepLinkTokenProps } from '../domain'; + +export const ltiDeepLinkTokenFactory = BaseFactory.define( + LtiDeepLinkToken, + () => { + const expiryTimestampMs = Date.now() + 1000000; + + return { + id: new ObjectId().toHexString(), + state: new UUID().toString(), + userId: new ObjectId().toHexString(), + expiresAt: new Date(expiryTimestampMs), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts new file mode 100644 index 00000000000..ddaa5216702 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts @@ -0,0 +1,17 @@ +import { BaseFactory } from '@shared/testing'; +import { CustomParameterEntry } from '../../common/domain'; +import { LtiDeepLink } from '../domain'; + +export const ltiDeepLinkFactory = BaseFactory.define(LtiDeepLink, ({ sequence }) => { + return { + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: `Deep Link Content ${sequence}`, + url: 'https://lti.deep.link', + text: 'Deep link description', + parameters: [new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' })], + availableFrom: new Date(), + availableUntil: new Date(), + submissionFrom: new Date(), + submissionUntil: new Date(), + }; +}); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts new file mode 100644 index 00000000000..21b697a96e0 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts @@ -0,0 +1,88 @@ +import { UUID } from 'bson'; +import CryptoJS from 'crypto-js'; +import { DeepPartial, Factory } from 'fishery'; +import OAuth, { Authorization, RequestOptions } from 'oauth-1.0a'; +import { Lti11ContentItemType, Lti11DeepLinkParams } from '../controller/dto'; +import { Lti11DeepLinkParamsRaw } from '../controller/dto/lti11-deep-link/lti11-deep-link-raw.params'; + +type Lti11DeepLinkParamsPayload = Omit; + +export const lti11DeepLinkParamsPayloadFactory = Factory.define(() => { + return { + lti_message_type: 'ContentItemSelection', + lti_version: 'LTI-1p0', + data: new UUID().toString(), + content_items: { + '@context': 'context', + '@graph': [ + { + '@type': Lti11ContentItemType.CONTENT_ITEM, + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: 'Deep Link Content', + text: 'descriptive text', + url: 'https://lti.deep.link', + available: { + startDatetime: new Date('2024-01'), + endDatetime: new Date('2024-02'), + }, + submission: { + startDatetime: new Date('2024-01'), + endDatetime: new Date('2024-02'), + }, + custom: { + dl_param: 'dl_value', + }, + }, + ], + }, + oauth_callback: 'about:blank', + }; +}); + +export class Lti11DeepLinkParamsFactory { + private readonly consumer: OAuth; + + constructor( + private readonly url: string = 'https://default.deep-link.url/callback', + private readonly key: string = 'defaultKey', + private readonly secret: string = 'defaultSecret' + ) { + this.consumer = new OAuth({ + consumer: { + key: this.key, + secret: this.secret, + }, + signature_method: 'HMAC-SHA1', + hash_function: (base_string: string, hashKey: string) => + CryptoJS.HmacSHA1(base_string, hashKey).toString(CryptoJS.enc.Base64), + }); + } + + build(params?: DeepPartial): Lti11DeepLinkParams { + const payload: Lti11DeepLinkParamsPayload = lti11DeepLinkParamsPayloadFactory.build(params); + + const requestData: RequestOptions = { + url: this.url, + method: 'POST', + data: payload, + }; + + const authorization: Authorization = this.consumer.authorize(requestData); + + return authorization as Lti11DeepLinkParams; + } + + buildRaw(params?: DeepPartial): Lti11DeepLinkParamsRaw { + const payload: Lti11DeepLinkParamsPayload = lti11DeepLinkParamsPayloadFactory.build(params); + + const requestData: RequestOptions = { + url: this.url, + method: 'POST', + data: { ...payload, content_items: JSON.stringify(payload.content_items) }, + }; + + const authorization: Authorization = this.consumer.authorize(requestData); + + return authorization as Lti11DeepLinkParamsRaw; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index cda6453c71d..fc4933dafeb 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; import { Action, @@ -14,31 +15,55 @@ import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { setupEntities, userFactory } from '@shared/testing'; -import { ToolContextType } from '../../common/enum'; +import { UUID } from 'bson'; +import { LtiMessageType, ToolContextType } from '../../common/enum'; +import { Lti11EncryptionService } from '../../common/service'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ExternalToolService } from '../../external-tool'; +import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; -import { ContextExternalTool, ContextExternalToolProps } from '../domain'; -import { ContextExternalToolService } from '../service'; +import { + ContextExternalTool, + ContextExternalToolProps, + InvalidOauthSignatureLoggableException, + InvalidToolTypeLoggableException, + LtiDeepLinkTokenMissingLoggableException, +} from '../domain'; +import { ContextExternalToolService, LtiDeepLinkingService, LtiDeepLinkTokenService } from '../service'; import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; -import { contextExternalToolFactory } from '../testing'; +import { + contextExternalToolFactory, + Lti11DeepLinkParamsFactory, + ltiDeepLinkFactory, + ltiDeepLinkTokenFactory, +} from '../testing'; import { ContextExternalToolUc } from './context-external-tool.uc'; describe(ContextExternalToolUc.name, () => { let module: TestingModule; let uc: ContextExternalToolUc; + let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; let contextExternalToolValidationService: DeepMocked; let toolPermissionHelper: DeepMocked; let authorizationService: DeepMocked; + let ltiDeepLinkTokenService: DeepMocked; + let ltiDeepLinkingService: DeepMocked; + let lti11EncryptionService: DeepMocked; + let encryptionService: DeepMocked; beforeAll(async () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ ContextExternalToolUc, + { + provide: ExternalToolService, + useValue: createMock(), + }, { provide: SchoolExternalToolService, useValue: createMock(), @@ -59,15 +84,36 @@ describe(ContextExternalToolUc.name, () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: LtiDeepLinkTokenService, + useValue: createMock(), + }, + { + provide: LtiDeepLinkingService, + useValue: createMock(), + }, + { + provide: Lti11EncryptionService, + useValue: createMock(), + }, + { + provide: DefaultEncryptionService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(ContextExternalToolUc); + externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); contextExternalToolValidationService = module.get(ContextExternalToolValidationService); toolPermissionHelper = module.get(ToolPermissionHelper); authorizationService = module.get(AuthorizationService); + ltiDeepLinkTokenService = module.get(LtiDeepLinkTokenService); + ltiDeepLinkingService = module.get(LtiDeepLinkingService); + lti11EncryptionService = module.get(Lti11EncryptionService); + encryptionService = module.get(DefaultEncryptionService); }); afterAll(async () => { @@ -347,6 +393,7 @@ describe(ContextExternalToolUc.name, () => { schoolId, }); + const ltiDeepLink = ltiDeepLinkFactory.build(); const contextExternalTool = contextExternalToolFactory.buildWithId({ displayName: 'Course', schoolToolRef: { @@ -357,6 +404,7 @@ describe(ContextExternalToolUc.name, () => { id: 'contextId', type: ToolContextType.COURSE, }, + ltiDeepLink, }); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); @@ -369,11 +417,12 @@ describe(ContextExternalToolUc.name, () => { contextExternalToolId: contextExternalTool.id, user, schoolId, + ltiDeepLink, }; }; it('should call contextExternalToolService', async () => { - const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId, ltiDeepLink } = setup(); await uc.updateContextExternalTool(user.id, schoolId, contextExternalToolId, contextExternalTool.getProps()); @@ -381,6 +430,7 @@ describe(ContextExternalToolUc.name, () => { expect.objectContaining({ ...contextExternalTool.getProps(), id: expect.any(String), + ltiDeepLink, }) ); }); @@ -823,4 +873,221 @@ describe(ContextExternalToolUc.name, () => { }); }); }); + + describe('updateLtiDeepLink', () => { + describe('when deep linking a content', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const key = 'key'; + const secret = 'secret'; + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const externalTool = externalToolFactory + .withLti11Config({ + key, + secret, + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + displayName: 'oldName', + }); + const linkedContextExternalTool = new ContextExternalTool({ + ...contextExternalTool.getProps(), + ltiDeepLink, + displayName: ltiDeepLink.title, + }); + const callbackUrl = 'https://this.cloud/lti-deep-link-callback'; + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + encryptionService.decrypt.mockReturnValueOnce('decryptedSecret'); + lti11EncryptionService.verify.mockReturnValueOnce(true); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + contextExternalToolService.saveContextExternalTool.mockResolvedValueOnce(linkedContextExternalTool); + + return { + contextExternalTool, + ltiDeepLink, + payload, + user, + key, + secret, + state, + callbackUrl, + linkedContextExternalTool, + }; + }; + + it('should check the oauth signature', async () => { + const { contextExternalTool, payload, ltiDeepLink, key, state, callbackUrl } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(lti11EncryptionService.verify).toHaveBeenCalledWith(key, 'decryptedSecret', callbackUrl, payload); + }); + + it('should check the user permission', async () => { + const { contextExternalTool, payload, ltiDeepLink, state, user } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + user, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); + }); + + it('should should save the linked tool', async () => { + const { contextExternalTool, payload, ltiDeepLink, state, linkedContextExternalTool } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(linkedContextExternalTool); + }); + }); + + describe('when no content was linked', () => { + const setup = () => { + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ data: state }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ state }); + const contextExternalTool = contextExternalToolFactory.build(); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + contextExternalTool, + payload, + state, + }; + }; + + it('should do nothing', async () => { + const { contextExternalTool, payload, state } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state); + + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + + describe('when deep linking a content', () => { + const setup = () => { + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const contextExternalTool = contextExternalToolFactory.build(); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(null); + + return { + contextExternalTool, + payload, + ltiDeepLink, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + LtiDeepLinkTokenMissingLoggableException + ); + }); + }); + + describe('when the external tool is not an lti 1.1 tool', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const externalTool = externalToolFactory.withBasicConfig().build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + }); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + + return { + contextExternalTool, + ltiDeepLink, + payload, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + InvalidToolTypeLoggableException + ); + }); + }); + + describe('when the oauth signature is invalid', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const externalTool = externalToolFactory + .withLti11Config({ lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST }) + .build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + }); + const callbackUrl = 'https://this.cloud/lti-deep-link-callback'; + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + encryptionService.decrypt.mockReturnValueOnce('decryptedSecret'); + lti11EncryptionService.verify.mockReturnValueOnce(false); + + return { + contextExternalTool, + ltiDeepLink, + payload, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + InvalidOauthSignatureLoggableException + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index dbea96596d3..33c0c7a5cf7 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -1,3 +1,4 @@ +import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { AuthorizationContext, AuthorizationContextBuilder, @@ -5,16 +6,28 @@ import { ForbiddenLoggableException, } from '@modules/authorization'; import { AuthorizableReferenceType } from '@modules/authorization/domain'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ToolContextType } from '../../common/enum'; +import { Authorization } from 'oauth-1.0a'; +import { ToolConfigType, ToolContextType } from '../../common/enum'; +import { Lti11EncryptionService } from '../../common/service'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ExternalToolService } from '../../external-tool'; +import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalTool, ContextRef } from '../domain'; -import { ContextExternalToolService } from '../service'; +import { + ContextExternalTool, + ContextRef, + InvalidOauthSignatureLoggableException, + InvalidToolTypeLoggableException, + LtiDeepLink, + LtiDeepLinkToken, + LtiDeepLinkTokenMissingLoggableException, +} from '../domain'; +import { ContextExternalToolService, LtiDeepLinkingService, LtiDeepLinkTokenService } from '../service'; import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; import { ContextExternalToolDto } from './dto/context-external-tool.types'; @@ -22,10 +35,15 @@ import { ContextExternalToolDto } from './dto/context-external-tool.types'; export class ContextExternalToolUc { constructor( private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, private readonly contextExternalToolValidationService: ContextExternalToolValidationService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly ltiDeepLinkTokenService: LtiDeepLinkTokenService, + private readonly ltiDeepLinkingService: LtiDeepLinkingService, + private readonly lti11EncryptionService: Lti11EncryptionService, + @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService ) {} async createContextExternalTool( @@ -81,6 +99,7 @@ export class ContextExternalToolUc { contextExternalTool = new ContextExternalTool({ ...contextExternalToolDto, id: contextExternalTool.id, + ltiDeepLink: contextExternalTool.ltiDeepLink, }); contextExternalTool.schoolToolRef.schoolId = schoolId; @@ -143,4 +162,63 @@ export class ContextExternalToolUc { return toolsWithPermission; } + + public async updateLtiDeepLink( + contextExternalToolId: EntityId, + payload: Authorization, + state: string, + deepLink?: LtiDeepLink + ): Promise { + if (!deepLink) { + return; + } + + const ltiDeepLinkToken: LtiDeepLinkToken | null = await this.ltiDeepLinkTokenService.findByState(state); + + if (!ltiDeepLinkToken) { + throw new LtiDeepLinkTokenMissingLoggableException(state, contextExternalToolId); + } + + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail( + contextExternalToolId + ); + + await this.checkOauthSignature(contextExternalTool, payload); + + const user: User = await this.authorizationService.getUserWithPermissions(ltiDeepLinkToken.userId); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); + + contextExternalTool.ltiDeepLink = deepLink; + if (deepLink.title) { + contextExternalTool.displayName = deepLink.title; + } + + await this.contextExternalToolService.saveContextExternalTool(contextExternalTool); + } + + private async checkOauthSignature(contextExternalTool: ContextExternalTool, payload: Authorization): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + + if (!ExternalTool.isLti11Config(externalTool.config)) { + throw new InvalidToolTypeLoggableException(ToolConfigType.LTI11, externalTool.config.type); + } + + const url: string = this.ltiDeepLinkingService.getCallbackUrl(contextExternalTool.id); + const decryptedSecret: string = this.encryptionService.decrypt(externalTool.config.secret); + + const isValidSignature: boolean = this.lti11EncryptionService.verify( + externalTool.config.key, + decryptedSecret, + url, + payload + ); + + if (!isValidSignature) { + throw new InvalidOauthSignatureLoggableException(); + } + } } diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts index 4b6519e0bf5..912d27b756d 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts @@ -70,6 +70,7 @@ describe('ToolReferenceUc', () => { isOutdatedOnScopeContext: false, }), openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: false, }); const contextType: ToolContextType = ToolContextType.COURSE; @@ -159,6 +160,7 @@ describe('ToolReferenceUc', () => { isOutdatedOnScopeContext: false, }), openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: false, }); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index e6d3c1df1ba..ef9c435c155 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -1,7 +1,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { CustomParameter } from '../../common/domain'; -import { ToolConfigType, ToolContextType } from '../../common/enum'; +import { LtiMessageType, ToolConfigType, ToolContextType } from '../../common/enum'; import { BasicToolConfig, ExternalToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from './config'; import { ExternalToolMedium } from './external-tool-medium.do'; import { FileRecordRef } from './file-record-ref'; @@ -212,4 +212,11 @@ export class ExternalTool extends DomainObject { static isLti11Config(config: ExternalToolConfig): config is Lti11ToolConfig { return ToolConfigType.LTI11 === config.type; } + + public isLtiDeepLinkingTool(): boolean { + return ( + ExternalTool.isLti11Config(this.config) && + this.config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST + ); + } } 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 677d33bf4cf..2bf5ad6f2c6 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 @@ -23,7 +23,7 @@ export class ExternalToolDatasheetTemplateDataFactory extends Factory = { toolType: 'OAuth 2.0', skipConsent: 'Zustimmung überspringen: ja', - toolUrl: 'https://www.oauth2-baseUrl.com/', + toolUrl: 'https://www.oauth2-baseurl.com/', }; return this.params(params); } @@ -33,7 +33,7 @@ export class ExternalToolDatasheetTemplateDataFactory extends Factory( + BasicToolConfigEntity, + () => { + return { + type: ToolConfigType.BASIC, + baseUrl: 'https://mock.de', + }; + } +); + +export const oauth2ToolConfigEntityFactory = BaseFactory.define( + Oauth2ToolConfigEntity, + ({ sequence }) => { + return { + type: ToolConfigType.OAUTH2, + baseUrl: 'https://mock.de', + clientId: `client-${sequence}`, + skipConsent: false, + }; + } +); + +export const lti11ToolConfigEntityFactory = BaseFactory.define( + Lti11ToolConfigEntity, + () => { + return { + type: ToolConfigType.LTI11, + baseUrl: 'https://mock.de', + key: 'key', + secret: 'secret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: 'de-DE', + }; + } +); + export class ExternalToolEntityFactory extends BaseFactory { withName(name: string): this { const params: DeepPartial = { @@ -28,40 +65,27 @@ export class ExternalToolEntityFactory extends BaseFactory): this { const params: DeepPartial = { - config: new BasicToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), + config: basicToolConfigEntityFactory.build(customParam), }; + return this.params(params); } - withOauth2Config(clientId: string): this { + withOauth2Config(customParam?: DeepPartial): this { const params: DeepPartial = { - config: new Oauth2ToolConfigEntity({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - clientId, - skipConsent: false, - }), + config: oauth2ToolConfigEntityFactory.build(customParam), }; + return this.params(params); } - withLti11Config(): this { + withLti11Config(customParam?: DeepPartial): this { const params: DeepPartial = { - config: new Lti11ToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - key: 'key', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - secret: 'secret', - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - launch_presentation_locale: 'de-DE', - }), + config: lti11ToolConfigEntityFactory.build(customParam), }; + return this.params(params); } @@ -114,10 +138,7 @@ export const externalToolEntityFactory = ExternalToolEntityFactory.define( description: 'This is a tool description', url: '', logoUrl: 'https://logourl.com', - config: new BasicToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), + config: basicToolConfigEntityFactory.build(), parameters: [customParameterEntityFactory.build()], isHidden: false, isDeactivated: false, 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 ac3005b33e7..2327eb4eee2 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 @@ -45,7 +45,7 @@ class Oauth2ToolConfigFactory extends DoBaseFactory { return { type: ToolConfigType.OAUTH2, - baseUrl: 'https://www.oauth2-baseUrl.com/', + baseUrl: 'https://www.oauth2-baseurl.com/', clientId: 'clientId', skipConsent: false, }; @@ -54,7 +54,7 @@ export const oauth2ToolConfigFactory = Oauth2ToolConfigFactory.define(Oauth2Tool export const lti11ToolConfigFactory = DoBaseFactory.define(Lti11ToolConfig, () => { return { type: ToolConfigType.LTI11, - baseUrl: 'https://www.lti11-baseUrl.com/', + baseUrl: 'https://www.lti11-baseurl.com/', key: 'key', secret: 'secret', privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, diff --git a/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts b/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts index 5cde8530834..4439f6ceff5 100644 --- a/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts @@ -2,16 +2,11 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Factory } from 'fishery'; import { FileRecordRef } from '../domain'; -export const fileRecordRefFactory = Factory.define(({ sequence }) => { - const fileName = `fileName-${sequence}`; - const fileRecordId = new ObjectId().toHexString(); - - return { - uploadUrl: 'uploadUrl', - fileName, - fileRecordId, - getPreviewUrl(): string { - return `/api/v3/file/preview/${fileRecordId}/${encodeURIComponent(fileName)}`; - }, - }; -}); +export const fileRecordRefFactory = Factory.define( + ({ sequence }) => + new FileRecordRef({ + uploadUrl: 'uploadUrl', + fileName: `fileName-${sequence}`, + fileRecordId: new ObjectId().toHexString(), + }) +); diff --git a/apps/server/src/modules/tool/external-tool/testing/index.ts b/apps/server/src/modules/tool/external-tool/testing/index.ts index 7dd621d10e0..ca4cdceb780 100644 --- a/apps/server/src/modules/tool/external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/external-tool/testing/index.ts @@ -1,4 +1,10 @@ -export { externalToolEntityFactory, customParameterEntityFactory } from './external-tool-entity.factory'; +export { + externalToolEntityFactory, + customParameterEntityFactory, + basicToolConfigEntityFactory, + oauth2ToolConfigEntityFactory, + lti11ToolConfigEntityFactory, +} from './external-tool-entity.factory'; export { externalToolFactory, customParameterFactory, diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index 51eec1332ff..1e334abbdd3 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -11,7 +11,7 @@ import { LoggerModule } from '@src/core/logger'; import { LearnroomModule } from '../learnroom'; import { CommonToolModule } from './common'; import { ToolPermissionHelper } from './common/uc/tool-permission-helper'; -import { ToolContextController } from './context-external-tool/controller'; +import { ToolContextController, ToolDeepLinkController } from './context-external-tool/controller'; import { ToolReferenceController } from './context-external-tool/controller/tool-reference.controller'; import { ContextExternalToolUc, ToolReferenceUc } from './context-external-tool/uc'; import { ToolConfigurationController, ToolController } from './external-tool/controller'; @@ -45,6 +45,7 @@ import { ToolModule } from './tool.module'; ToolContextController, ToolReferenceController, ToolController, + ToolDeepLinkController, ], providers: [ LtiToolRepo, diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index 099a732ab0d..2b65f1b8f3e 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -8,4 +8,5 @@ export interface ToolConfig { FILES_STORAGE__SERVICE_BASE_URL: string; CTL_TOOLS__PREFERRED_TOOLS_LIMIT: number; FEATURE_PREFERRED_CTL_TOOLS_ENABLED: boolean; + PUBLIC_BACKEND_URL: string; } 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 4a695caa828..82f8efba10f 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 @@ -26,7 +26,7 @@ import { } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod } from '../../types'; +import { LaunchRequestMethod, LaunchType } from '../../types'; import { ContextExternalToolBodyParams, ContextExternalToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; describe('ToolLaunchController (API)', () => { @@ -123,7 +123,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, - isDeepLink: false, + launchType: LaunchType.BASIC, }); }); }); @@ -414,7 +414,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, - isDeepLink: false, + launchType: LaunchType.BASIC, }); }); }); 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 a488bca921a..fb8f09300ee 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 @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { LaunchRequestMethod } from '../../types'; +import { LaunchRequestMethod, LaunchType } from '../../types'; export class ToolLaunchRequestResponse { @ApiProperty({ @@ -30,15 +30,17 @@ export class ToolLaunchRequestResponse { openNewTab?: boolean; @ApiProperty({ - description: 'Specifies whether the request is an LTI Deep linking content item selection request', + description: 'Specifies the underlying type of the request', + enum: LaunchType, + enumName: 'LaunchType', }) - isDeepLink: boolean; + launchType: LaunchType; constructor(props: ToolLaunchRequestResponse) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; - this.isDeepLink = props.isDeepLink; + this.launchType = props.launchType; } } 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 9a095fbf116..5632f65a19d 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,6 +1,6 @@ import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; import { ToolLaunchRequestResponse } from '../controller/dto'; -import { LaunchRequestMethod, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; +import { LaunchRequestMethod, LaunchType, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchMapper } from './tool-launch.mapper'; describe('ToolLaunchMapper', () => { @@ -33,7 +33,7 @@ describe('ToolLaunchMapper', () => { url: 'url', openNewTab: true, payload: 'payload', - isDeepLink: false, + launchType: LaunchType.BASIC, }); const result: ToolLaunchRequestResponse = ToolLaunchMapper.mapToToolLaunchRequestResponse(toolLaunchRequest); @@ -43,7 +43,7 @@ describe('ToolLaunchMapper', () => { url: toolLaunchRequest.url, payload: toolLaunchRequest.payload, openNewTab: toolLaunchRequest.openNewTab, - isDeepLink: toolLaunchRequest.isDeepLink, + launchType: LaunchType.BASIC, }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/index.ts b/apps/server/src/modules/tool/tool-launch/service/index.ts index 9b8c27189b8..7da3dfd168e 100644 --- a/apps/server/src/modules/tool/tool-launch/service/index.ts +++ b/apps/server/src/modules/tool/tool-launch/service/index.ts @@ -1,2 +1 @@ export * from './tool-launch.service'; -export * from './lti11-encryption.service'; 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 c2a049acef4..146e0eae91b 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 @@ -17,7 +17,7 @@ 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, ToolLaunchRequest } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -60,6 +60,10 @@ class TestLaunchStrategy extends AbstractLaunchStrategy { public override determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod { return launchMethod; } + + determineLaunchType(): LaunchType { + return LaunchType.BASIC; + } } describe(AbstractLaunchStrategy.name, () => { @@ -345,8 +349,8 @@ describe(AbstractLaunchStrategy.name, () => { url: expectedUrl.toString(), method: strategy.determineLaunchRequestMethod(expectedProperties), openNewTab: false, - isDeepLink: false, payload: strategy.buildToolLaunchRequestPayload(expectedUrl.toString(), expectedProperties), + launchType: strategy.determineLaunchType(), }); }); }); @@ -385,8 +389,8 @@ describe(AbstractLaunchStrategy.name, () => { url: externalTool.config.baseUrl, method: strategy.determineLaunchRequestMethod([concreteConfigParameter]), openNewTab: false, - isDeepLink: false, payload: strategy.buildToolLaunchRequestPayload(externalTool.config.baseUrl, [concreteConfigParameter]), + launchType: strategy.determineLaunchType(), }); }); }); 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 7d5b413a9f7..bf4885ff247 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 @@ -8,15 +8,22 @@ import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; import { ToolLaunchMapper } from '../../mapper'; -import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchData, ToolLaunchRequest } from '../../types'; +import { + LaunchRequestMethod, + LaunchType, + PropertyData, + PropertyLocation, + ToolLaunchData, + ToolLaunchRequest, +} from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoParameterStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; import { ToolLaunchStrategy } from './tool-launch-strategy.interface'; @@ -52,7 +59,9 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { public abstract determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod; - public async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { + public abstract determineLaunchType(): LaunchType; + + protected async createLaunchData(userId: EntityId, data: ToolLaunchParams): Promise { const launchData: ToolLaunchData = this.buildToolLaunchDataFromExternalTool(data.externalTool); const launchDataProperties: PropertyData[] = await this.buildToolLaunchDataFromTools(data); @@ -64,6 +73,12 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { launchData.properties.push(...launchDataProperties); launchData.properties.push(...additionalLaunchDataProperties); + return launchData; + } + + public async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { + const launchData: ToolLaunchData = await this.createLaunchData(userId, data); + const requestMethod: LaunchRequestMethod = this.determineLaunchRequestMethod(launchData.properties); const url: string = this.buildUrl(launchData); const payload: string | null = this.buildToolLaunchRequestPayload(url, launchData.properties); @@ -73,13 +88,13 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { url, payload: payload ?? undefined, openNewTab: launchData.openNewTab, - isDeepLink: false, + launchType: this.determineLaunchType(), }); return toolLaunchRequest; } - private buildUrl(toolLaunchDataDO: ToolLaunchData): string { + protected buildUrl(toolLaunchDataDO: ToolLaunchData): string { const { baseUrl } = toolLaunchDataDO; const pathProperties: PropertyData[] = toolLaunchDataDO.properties.filter( diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts index db7bda486d0..e5c99b8389c 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts @@ -6,21 +6,21 @@ 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, LaunchType, PropertyData, PropertyLocation } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { BasicToolLaunchStrategy } from './basic-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; describe('BasicToolLaunchStrategy', () => { let module: TestingModule; - let basicToolLaunchStrategy: BasicToolLaunchStrategy; + let strategy: BasicToolLaunchStrategy; beforeAll(async () => { module = await Test.createTestingModule({ @@ -53,7 +53,7 @@ describe('BasicToolLaunchStrategy', () => { ], }).compile(); - basicToolLaunchStrategy = module.get(BasicToolLaunchStrategy); + strategy = module.get(BasicToolLaunchStrategy); }); afterAll(async () => { @@ -83,7 +83,7 @@ describe('BasicToolLaunchStrategy', () => { it('should return null', () => { const { properties } = setup(); - const payload: string | null = basicToolLaunchStrategy.buildToolLaunchRequestPayload('url', properties); + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); expect(payload).toBeNull(); }); @@ -117,7 +117,7 @@ describe('BasicToolLaunchStrategy', () => { it('should build the tool launch request payload correctly', () => { const { properties } = setup(); - const payload: string | null = basicToolLaunchStrategy.buildToolLaunchRequestPayload('url', properties); + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); expect(payload).toEqual('{"param1":"value1","param2":"value2"}'); }); @@ -142,10 +142,7 @@ describe('BasicToolLaunchStrategy', () => { it('should build the tool launch data from the basic tool config correctly', async () => { const { data } = setup(); - const result: PropertyData[] = await basicToolLaunchStrategy.buildToolLaunchDataFromConcreteConfig( - 'userId', - data - ); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); expect(result).toEqual([]); }); @@ -174,7 +171,7 @@ describe('BasicToolLaunchStrategy', () => { it('should return GET', () => { const { properties } = setup(); - const result: LaunchRequestMethod = basicToolLaunchStrategy.determineLaunchRequestMethod(properties); + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod(properties); expect(result).toEqual(LaunchRequestMethod.GET); }); @@ -208,10 +205,20 @@ describe('BasicToolLaunchStrategy', () => { it('should return POST', () => { const { properties } = setup(); - const result: LaunchRequestMethod = basicToolLaunchStrategy.determineLaunchRequestMethod(properties); + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod(properties); expect(result).toEqual(LaunchRequestMethod.POST); }); }); }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return basic', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.BASIC); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts index 1cf107eedcb..a168e846928 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation } from '../../types'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -41,4 +41,8 @@ export class BasicToolLaunchStrategy extends AbstractLaunchStrategy { return launchRequestMethod; } + + public override determineLaunchType(): LaunchType { + return LaunchType.BASIC; + } } 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 941b2680ec5..8a8c25831df 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 @@ -11,14 +11,24 @@ 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 { CustomParameterEntry } from '../../../common/domain'; import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; -import { contextExternalToolFactory } from '../../../context-external-tool/testing'; +import { Lti11EncryptionService } from '../../../common/service'; +import { + ContextExternalTool, + LtiMessageTypeNotImplementedLoggableException, +} from '../../../context-external-tool/domain'; +import { LtiDeepLinkingService, LtiDeepLinkTokenService } from '../../../context-external-tool/service'; +import { + contextExternalToolFactory, + ltiDeepLinkFactory, + ltiDeepLinkTokenFactory, +} 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, ToolLaunchRequest } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -27,7 +37,6 @@ import { AutoSchoolIdStrategy, AutoSchoolNumberStrategy, } from '../auto-parameter-strategy'; -import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -38,6 +47,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { let userService: DeepMocked; let pseudonymService: DeepMocked; let lti11EncryptionService: DeepMocked; + let ltiDeepLinkTokenService: DeepMocked; + let ltiDeepLinkingService: DeepMocked; let encryptionService: DeepMocked; beforeAll(async () => { @@ -56,6 +67,14 @@ describe(Lti11ToolLaunchStrategy.name, () => { provide: Lti11EncryptionService, useValue: createMock(), }, + { + provide: LtiDeepLinkTokenService, + useValue: createMock(), + }, + { + provide: LtiDeepLinkingService, + useValue: createMock(), + }, { provide: AutoSchoolIdStrategy, useValue: createMock(), @@ -92,6 +111,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { userService = module.get(UserService); pseudonymService = module.get(PseudonymService); lti11EncryptionService = module.get(Lti11EncryptionService); + ltiDeepLinkTokenService = module.get(LtiDeepLinkTokenService); + ltiDeepLinkingService = module.get(LtiDeepLinkingService); encryptionService = module.get(DefaultEncryptionService); }); @@ -104,98 +125,351 @@ describe(Lti11ToolLaunchStrategy.name, () => { }); describe('buildToolLaunchDataFromConcreteConfig', () => { - describe('when building the launch data for the encryption', () => { - const setup = () => { - const mockKey = 'mockKey'; - const mockSecret = 'mockSecret'; - const ltiMessageType = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; - const launchPresentationLocale = 'de-DE'; + describe('when lti messageType is basic lti launch request', () => { + describe('when building the launch data for the encryption', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const launchPresentationLocale = 'de-DE'; + + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: mockKey, + secret: mockSecret, + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PUBLIC, + launch_presentation_locale: launchPresentationLocale, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }); + + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + userService.findById.mockResolvedValue(user); + + return { + data, + decrypted, + user, + mockKey, + mockSecret, + contextExternalTool, + launchPresentationLocale, + }; + }; - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: mockKey, - secret: mockSecret, - lti_message_type: ltiMessageType, - privacy_permission: LtiPrivacyPermission.PUBLIC, - launch_presentation_locale: launchPresentationLocale, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + it('should contain lti key and secret without location', async () => { + const { data, mockKey, decrypted } = setup(); - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ name: 'key', value: mockKey }), + new PropertyData({ name: 'secret', value: decrypted }), + ]) + ); + }); + + it('should contain mandatory lti attributes', async () => { + const { data, contextExternalTool, launchPresentationLocale } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + 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', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PUBLIC, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const userEmail = 'user@email.com'; + const user: UserDO = userDoFactory.buildWithId( { - id: 'roleId2', - name: RoleName.USER, + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], }, - ], - }); + userId + ); - const decrypted = 'decryptedSecret'; - encryptionService.decrypt.mockReturnValue(decrypted); - userService.findById.mockResolvedValue(user); + const userDisplayName = 'Hans Peter Test'; - return { - data, - decrypted, - user, - mockKey, - mockSecret, - ltiMessageType, - contextExternalTool, - launchPresentationLocale, + userService.findById.mockResolvedValue(user); + userService.getDisplayName.mockResolvedValue(userDisplayName); + + return { + data, + userId, + userDisplayName, + userEmail, + }; }; - }; - it('should contain lti key and secret without location', async () => { - const { data, mockKey, decrypted } = setup(); + it('should contain all user related attributes', async () => { + const { data, userId, userDisplayName, userEmail } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_name_full', + value: userDisplayName, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_contact_email_primary', + value: userEmail, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + }); + }); + + describe('when lti privacyPermission is name', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.NAME, + }) + .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 result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const userDisplayName = 'Hans Peter Test'; - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ name: 'key', value: mockKey }), - new PropertyData({ name: 'secret', value: decrypted }), - ]) - ); + userService.findById.mockResolvedValue(user); + userService.getDisplayName.mockResolvedValue(userDisplayName); + + return { + data, + userId, + userDisplayName, + }; + }; + + it('should contain the user name and id', async () => { + const { data, userId, userDisplayName } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_name_full', + value: userDisplayName, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'lis_person_contact_email_primary' })]) + ); + }); }); - }); - describe('when lti privacyPermission is public', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.PUBLIC, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + describe('when lti privacyPermission is email', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.EMAIL, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const userEmail = 'user@email.com'; + const user: UserDO = userDoFactory.buildWithId( + { + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, + userService.findById.mockResolvedValue(user); + + return { + data, + userId, + userEmail, + }; }; - const userId: string = new ObjectId().toHexString(); - const userEmail = 'user@email.com'; - const user: UserDO = userDoFactory.buildWithId( - { - email: userEmail, + it('should contain the user email and id', async () => { + const { data, userId, userEmail } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_contact_email_primary', + value: userEmail, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'lis_person_name_full' })]) + ); + }); + }); + + describe('when lti privacyPermission is pseudonymous', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', @@ -206,69 +480,67 @@ describe(Lti11ToolLaunchStrategy.name, () => { name: RoleName.USER, }, ], - }, - userId - ); + }); - const userDisplayName = 'Hans Peter Test'; + const pseudonym: Pseudonym = pseudonymFactory.build(); - userService.findById.mockResolvedValue(user); - userService.getDisplayName.mockResolvedValue(userDisplayName); + userService.findById.mockResolvedValue(user); + pseudonymService.findOrCreatePseudonym.mockResolvedValue(pseudonym); - return { - data, - userId, - userDisplayName, - userEmail, + return { + data, + pseudonym, + }; }; - }; - - it('should contain all user related attributes', async () => { - const { data, userId, userDisplayName, userEmail } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); - - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), - new PropertyData({ - name: 'lis_person_contact_email_primary', - value: userEmail, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); + it('should contain the pseudonymised user id', async () => { + const { data, pseudonym } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'user_id', + value: pseudonym.pseudonym, + location: PropertyLocation.BODY, + }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + ]) + ); + }); }); - }); - - describe('when lti privacyPermission is name', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.NAME, - }) - .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( - { + describe('when lti privacyPermission is anonymous', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', @@ -279,190 +551,388 @@ describe(Lti11ToolLaunchStrategy.name, () => { name: RoleName.USER, }, ], - }, - userId - ); - - const userDisplayName = 'Hans Peter Test'; + }); - userService.findById.mockResolvedValue(user); - userService.getDisplayName.mockResolvedValue(userDisplayName); + userService.findById.mockResolvedValue(user); - return { - data, - userId, - userDisplayName, + return { + data, + }; }; - }; - it('should contain the user name and id', async () => { - const { data, userId, userDisplayName } = setup(); + it('should not contain user related information', async () => { + const { data } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'lis_person_contact_email_primary' })]) - ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + expect.objectContaining({ name: 'user_id' }), + expect.objectContaining({ name: 'roles' }), + ]) + ); + }); }); - }); - - describe('when lti privacyPermission is email', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.EMAIL, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; - const userId: string = new ObjectId().toHexString(); - const userEmail = 'user@email.com'; - const user: UserDO = userDoFactory.buildWithId( - { - email: userEmail, + describe('when context external tool id is undefined', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_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 user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', name: RoleName.TEACHER, }, - { - id: 'roleId2', - name: RoleName.USER, - }, ], - }, - userId - ); + }); - userService.findById.mockResolvedValue(user); + userService.findById.mockResolvedValue(user); - return { - data, - userId, - userEmail, + return { + data, + }; }; - }; - it('should contain the user email and id', async () => { - const { data, userId, userEmail } = setup(); + it('should use a random id', async () => { + const { data } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const result = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), + expect(result).toContainEqual( new PropertyData({ - name: 'lis_person_contact_email_primary', - value: userEmail, + name: 'resource_link_id', + value: expect.any(String), location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'lis_person_name_full' })])); + }) + ); + }); }); }); - describe('when lti privacyPermission is pseudonymous', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, + describe('when lti messageType is content item selection request', () => { + describe('when no content is linked to the 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 contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + 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); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + ltiDeepLinkTokenService.generateToken.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + data, + userId, + callbackUrl, + ltiDeepLinkToken, + }; }; - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], + it('should contain the attributes for a content item selection request', async () => { + const { data, userId, callbackUrl, ltiDeepLinkToken } = 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: ltiDeepLinkToken.state, + location: PropertyLocation.BODY, + }), + ]) + ); }); + }); - const pseudonym: Pseudonym = pseudonymFactory.build(); + describe('when the linked content is an lti launch', () => { + const setup = () => { + const launchPresentationLocale = 'de-DE'; + + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: launchPresentationLocale, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const ltiDeepLinkParameter = new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + parameters: [ltiDeepLinkParameter], + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + contextExternalTool, + launchPresentationLocale, + ltiDeepLinkParameter, + }; + }; - userService.findById.mockResolvedValue(user); - pseudonymService.findOrCreatePseudonym.mockResolvedValue(pseudonym); + it('should contain the attributes for a basic lti launch request with the additional attributes from the deep link', async () => { + const { data, userId, contextExternalTool, launchPresentationLocale, ltiDeepLinkParameter } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + 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, + }), + new PropertyData({ + name: `custom_${ltiDeepLinkParameter.name}`, + value: ltiDeepLinkParameter.value as string, + location: PropertyLocation.BODY, + }), + ]) + ); + }); + }); - return { - data, - pseudonym, + describe('when the linked content does not require an lti launch', () => { + 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 ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/pdf', + parameters: undefined, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + contextExternalTool, + }; }; - }; - it('should contain the pseudonymised user id', async () => { - const { data, pseudonym } = setup(); + it('should not contain parameters', async () => { + const { data, userId } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: pseudonym.pseudonym, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'lis_person_name_full' }), - expect.objectContaining({ name: 'lis_person_contact_email_primary' }), - ]) - ); + expect(result).toEqual([]); + }); + }); + + describe('when the tool is not permanent', () => { + 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(undefined, 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('when lti privacyPermission is anonymous', () => { + describe('when the lti message type is unknown', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.ANONYMOUS, + lti_message_type: 'unknown' as unknown as LtiMessageType, }) .build(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); @@ -474,38 +944,20 @@ describe(Lti11ToolLaunchStrategy.name, () => { externalTool, }; - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], - }); - - userService.findById.mockResolvedValue(user); + const userId: string = new ObjectId().toHexString(); return { data, + userId, + contextExternalTool, }; }; - it('should not contain user related information', async () => { - const { data } = setup(); - - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + it('should throw an error', async () => { + const { data, userId } = setup(); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'lis_person_name_full' }), - expect.objectContaining({ name: 'lis_person_contact_email_primary' }), - expect.objectContaining({ name: 'user_id' }), - expect.objectContaining({ name: 'roles' }), - ]) + await expect(() => strategy.buildToolLaunchDataFromConcreteConfig(userId, data)).rejects.toThrow( + LtiMessageTypeNotImplementedLoggableException ); }); }); @@ -539,249 +991,263 @@ describe(Lti11ToolLaunchStrategy.name, () => { ); }); }); + }); - describe('when context external tool id is undefined', () => { + describe('buildToolLaunchRequestPayload', () => { + describe('when key and secret are provided', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const pseudoContextExternalTool = { - ...contextExternalToolFactory.build().getProps(), - id: undefined, - }; + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); - const data: ToolLaunchParams = { - contextExternalTool: pseudoContextExternalTool, - schoolExternalTool, - externalTool, - }; + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - ], + const property3: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.PATH, }); - userService.findById.mockResolvedValue(user); + const mockKey = 'mockKey'; + const keyProperty: PropertyData = new PropertyData({ + name: 'key', + value: mockKey, + }); + + const secretProperty: PropertyData = new PropertyData({ + name: 'secret', + value: 'mockSecret', + }); + + const url = 'https://example.com/'; + + 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, + }; + + lti11EncryptionService.sign.mockReturnValue(signedPayload); return { - data, + properties: [property1, property2, property3, keyProperty, secretProperty], + url, + signedPayload, }; }; - it('should use a random id', async () => { - const { data } = setup(); + it('should return a OAuth1 signed payload', () => { + const { properties, signedPayload } = setup(); + + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); - const result = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + expect(payload).toEqual(JSON.stringify(signedPayload)); + }); - expect(result).toContainEqual( - new PropertyData({ - name: 'resource_link_id', - value: expect.any(String), - location: PropertyLocation.BODY, - }) + it('should not return a payload with the signing secret', () => { + const { properties } = setup(); + + strategy.buildToolLaunchRequestPayload('url', properties); + + expect(lti11EncryptionService.sign).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ secret: expect.anything() }) ); }); }); - describe('when lti messageType is content item selection request', () => { + describe('when key or secret is missing', () => { 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 property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); - const userId: string = new ObjectId().toHexString(); - const user: UserDO = userDoFactory.buildWithId( - { - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], - }, - userId - ); + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); - const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; - const callbackUrl = `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalTool.id}/lti11-deep-link-callback`; + const property3: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.PATH, + }); - userService.findById.mockResolvedValue(user); - const decrypted = 'decryptedSecret'; - encryptionService.decrypt.mockReturnValue(decrypted); + const url = 'https://example.com/'; return { - data, - userId, - callbackUrl, + properties: [property1, property2, property3], + url, }; }; - it('should contain all user related attributes', async () => { - const { data, userId, callbackUrl } = setup(); + it('should throw an InternalServerErrorException', () => { + const { properties } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const func = () => strategy.buildToolLaunchRequestPayload('url', properties); - 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, - }), - ]) + expect(func).toThrow( + new InternalServerErrorException( + 'Unable to build LTI 1.1 launch payload. "key" or "secret" is undefined in PropertyData' + ) ); }); }); + }); + + describe('determineLaunchRequestMethod', () => { + it('should return POST', () => { + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod([]); + + expect(result).toEqual(LaunchRequestMethod.POST); + }); + }); - describe('when a content item selection request is made without a permanent tool', () => { + describe('createLaunchRequest', () => { + describe('when lti message type is content item selection request and no content is selected', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory + 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: SchoolExternalTool = schoolExternalToolFactory.build(); - const pseudoContextExternalTool = { - ...contextExternalToolFactory.build().getProps(), - id: undefined, - }; + .build({ + openNewTab: false, + }); + + 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: pseudoContextExternalTool, + 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 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, + }; userService.findById.mockResolvedValue(user); const decrypted = 'decryptedSecret'; encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { + signedPayload, data, userId, }; }; - it('should throw an error', async () => { - const { data, userId } = setup(); + it('should create a post request with a signed payload and open in a new tab', async () => { + const { signedPayload, 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' - ) - ); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: true, + launchType: LaunchType.LTI11_CONTENT_ITEM_SELECTION, + }); }); }); - }); - describe('buildToolLaunchRequestPayload', () => { - describe('when key and secret are provided', () => { + describe('when there is a deep link with a url', () => { 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({ + openNewTab: false, + }); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + url: 'https://lti.deep.link', + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + const property1: PropertyData = new PropertyData({ name: 'param1', value: 'value1', @@ -794,27 +1260,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { location: PropertyLocation.BODY, }); - const property3: PropertyData = new PropertyData({ - name: 'param2', - value: 'value2', - location: PropertyLocation.PATH, - }); - - const mockKey = 'mockKey'; - const keyProperty: PropertyData = new PropertyData({ - name: 'key', - value: mockKey, - }); - - const secretProperty: PropertyData = new PropertyData({ - name: 'secret', - value: 'mockSecret', - }); - - const url = 'https://example.com/'; - const signedPayload: Authorization = { - oauth_consumer_key: mockKey, + oauth_consumer_key: 'mockKey', oauth_nonce: 'nonce', oauth_signature: 'signature', oauth_signature_method: 'HMAC-SHA1', @@ -824,40 +1271,142 @@ describe(Lti11ToolLaunchStrategy.name, () => { [property2.name]: property2.value, }; - lti11EncryptionService.sign.mockReturnValue(signedPayload); + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - properties: [property1, property2, property3, keyProperty, secretProperty], - url, signedPayload, + data, + userId, + ltiDeepLink, }; }; - it('should return a OAuth1 signed payload', () => { - const { properties, signedPayload } = setup(); + it('should use the deep link url', async () => { + const { signedPayload, data, userId, ltiDeepLink } = setup(); - const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(payload).toEqual(JSON.stringify(signedPayload)); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: ltiDeepLink.url as string, + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); + }); - it('should not return a payload with the signing secret', () => { - const { properties } = setup(); + describe('when there is a deep link resource that does not require an lti launch', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); - strategy.buildToolLaunchRequestPayload('url', properties); + const externalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build({ + openNewTab: false, + }); - expect(lti11EncryptionService.sign).not.toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({ secret: expect.anything() }) - ); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/pdf', + url: undefined, + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + ltiDeepLink, + }; + }; + + it('should use the GET method without a payload', async () => { + const { data, userId } = setup(); + + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual({ + method: LaunchRequestMethod.GET, + url: 'https://www.lti11-baseurl.com/', + payload: undefined, + openNewTab: false, + launchType: LaunchType.BASIC, + }); }); }); - describe('when key or secret is missing', () => { + describe('when there is a deep link resource of type lti assignment', () => { 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({ + openNewTab: false, + }); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + url: undefined, + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + const property1: PropertyData = new PropertyData({ name: 'param1', value: 'value1', @@ -870,44 +1419,46 @@ describe(Lti11ToolLaunchStrategy.name, () => { location: PropertyLocation.BODY, }); - const property3: PropertyData = new PropertyData({ - name: 'param2', - value: 'value2', - location: PropertyLocation.PATH, - }); + 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 url = 'https://example.com/'; + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - properties: [property1, property2, property3], - url, + signedPayload, + data, + userId, + ltiDeepLink, }; }; - it('should throw an InternalServerErrorException', () => { - const { properties } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); - const func = () => strategy.buildToolLaunchRequestPayload('url', properties); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(func).toThrow( - new InternalServerErrorException( - 'Unable to build LTI 1.1 launch payload. "key" or "secret" is undefined in PropertyData' - ) - ); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); }); - }); - - describe('determineLaunchRequestMethod', () => { - it('should return POST', () => { - const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod([]); - - expect(result).toEqual(LaunchRequestMethod.POST); - }); - }); - describe('createLaunchRequest', () => { - describe('when lti message type is content item selection request', () => { + describe('when there is a deep link resource of type lti link', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const user: UserDO = userDoFactory.buildWithId({ id: userId }); @@ -919,9 +1470,15 @@ describe(Lti11ToolLaunchStrategy.name, () => { lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, }) - .build(); + .build({ + openNewTab: false, + }); const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + url: undefined, + }); const contextExternalToolId = 'contextExternalToolId'; const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ @@ -933,6 +1490,7 @@ describe(Lti11ToolLaunchStrategy.name, () => { contextRef: { type: ToolContextType.COURSE, }, + ltiDeepLink, }); const data: ToolLaunchParams = { @@ -964,35 +1522,35 @@ describe(Lti11ToolLaunchStrategy.name, () => { [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); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - toolLaunchRequest, + signedPayload, data, userId, + ltiDeepLink, }; }; - it('should create a LaunchRequest with the correct method, url and payload', async () => { - const { toolLaunchRequest, data, userId } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(result).toEqual(toolLaunchRequest); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); }); - describe('when lti message type is not content item selection request and no deeplink', () => { + describe('when lti message type is basic lti launch request', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const user: UserDO = userDoFactory.buildWithId({ id: userId }); @@ -1010,7 +1568,9 @@ describe(Lti11ToolLaunchStrategy.name, () => { privacy_permission: LtiPrivacyPermission.PUBLIC, launch_presentation_locale: launchPresentationLocale, }) - .build(); + .build({ + openNewTab: false, + }); const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); @@ -1055,31 +1615,40 @@ describe(Lti11ToolLaunchStrategy.name, () => { [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); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - toolLaunchRequest, + signedPayload, data, userId, }; }; - it('should create a LaunchRequest with the correct method, url and payload', async () => { - const { toolLaunchRequest, data, userId } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(result).toEqual(toolLaunchRequest); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); + }); + }); + }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return lti basic launch', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.LTI11_BASIC_LAUNCH); }); }); }); 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 912c25980d4..567c30116d5 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,4 +1,3 @@ -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'; @@ -7,16 +6,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 { CustomParameterEntry } from '../../../common/domain'; import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; +import { Lti11EncryptionService } from '../../../common/service'; +import { + LtiDeepLink, + LtiDeepLinkToken, + LtiMessageTypeNotImplementedLoggableException, +} from '../../../context-external-tool/domain'; +import { LtiDeepLinkingService, LtiDeepLinkTokenService } from '../../../context-external-tool/service'; import { ExternalTool, Lti11ToolConfig } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; import { AuthenticationValues, LaunchRequestMethod, + LaunchType, PropertyData, PropertyLocation, + ToolLaunchData, ToolLaunchRequest, } from '../../types'; import { @@ -27,7 +35,6 @@ import { AutoSchoolIdStrategy, AutoSchoolNumberStrategy, } from '../auto-parameter-strategy'; -import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -37,6 +44,8 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { private readonly userService: UserService, private readonly pseudonymService: PseudonymService, private readonly lti11EncryptionService: Lti11EncryptionService, + private readonly ltiDeepLinkTokenService: LtiDeepLinkTokenService, + private readonly ltiDeepLinkingService: LtiDeepLinkingService, @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService, autoSchoolIdStrategy: AutoSchoolIdStrategy, autoSchoolNumberStrategy: AutoSchoolNumberStrategy, @@ -69,15 +78,38 @@ 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 - ); + switch (config.lti_message_type) { + case LtiMessageType.BASIC_LTI_LAUNCH_REQUEST: { + properties = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.BASIC_LTI_LAUNCH_REQUEST + ); + break; + } + case LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST: { + if (!data.contextExternalTool.ltiDeepLink) { + properties = await this.buildToolLaunchDataForContentItemSelectionRequest(userId, data, config); + } else if ( + data.contextExternalTool.ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltilink' || + data.contextExternalTool.ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltiassignment' + ) { + properties = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.BASIC_LTI_LAUNCH_REQUEST + ); + + properties.push(...this.buildToolLaunchDataFromDeepLink(data.contextExternalTool.ltiDeepLink)); + } else { + properties = []; + } + break; + } + default: + throw new LtiMessageTypeNotImplementedLoggableException(config.lti_message_type); } return properties; @@ -101,17 +133,14 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { 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 callbackUrl: string = this.ltiDeepLinkingService.getCallbackUrl(data.contextExternalTool.id); - const state = new UUID().toString(); + const ltiDeepLinkToken: LtiDeepLinkToken = await this.ltiDeepLinkTokenService.generateToken(userId); additionalProperties.push( new PropertyData({ name: 'content_item_return_url', - value: callbackUrl.toString(), + value: callbackUrl, location: PropertyLocation.BODY, }), new PropertyData({ @@ -148,7 +177,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { }), new PropertyData({ name: 'data', - value: state, + value: ltiDeepLinkToken.state, location: PropertyLocation.BODY, }) ); @@ -256,6 +285,24 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { return additionalProperties; } + private buildToolLaunchDataFromDeepLink(deepLink: LtiDeepLink): PropertyData[] { + const deepLinkProperties: PropertyData[] = []; + + deepLink.parameters.forEach((parameter: CustomParameterEntry): void => { + if (parameter.value) { + deepLinkProperties.push( + new PropertyData({ + name: `custom_${parameter.name}`, + value: parameter.value, + location: PropertyLocation.BODY, + }) + ); + } + }); + + return deepLinkProperties; + } + // eslint-disable-next-line @typescript-eslint/require-await public override buildToolLaunchRequestPayload(url: string, properties: PropertyData[]): string | null { const bodyProperties: PropertyData[] = properties.filter( @@ -302,17 +349,54 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { return LaunchRequestMethod.POST; } + public override determineLaunchType(): LaunchType { + return LaunchType.LTI11_BASIC_LAUNCH; + } + public override async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { - const request: ToolLaunchRequest = await super.createLaunchRequest(userId, data); + const launchData: ToolLaunchData = await this.createLaunchData(userId, data); + const { ltiDeepLink } = data.contextExternalTool; - if ( - ExternalTool.isLti11Config(data.externalTool.config) && - data.externalTool.config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST - ) { - request.openNewTab = true; - request.isDeepLink = true; + let method: LaunchRequestMethod; + let url: string; + let payload: string | null; + let launchType: LaunchType; + let { openNewTab } = launchData; + + if (ltiDeepLink?.url) { + url = ltiDeepLink?.url; + } else { + url = this.buildUrl(launchData); + } + + const isLtiLaunch: boolean = + !ltiDeepLink || + ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltilink' || + ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltiassignment'; + if (isLtiLaunch) { + method = this.determineLaunchRequestMethod(launchData.properties); + payload = this.buildToolLaunchRequestPayload(url, launchData.properties); + launchType = this.determineLaunchType(); + } else { + method = LaunchRequestMethod.GET; + payload = null; + launchType = LaunchType.BASIC; } - return request; + const isContentItemSelectionRequest: boolean = data.externalTool.isLtiDeepLinkingTool() && !ltiDeepLink; + if (isContentItemSelectionRequest) { + openNewTab = true; + launchType = LaunchType.LTI11_CONTENT_ITEM_SELECTION; + } + + const toolLaunchRequest = new ToolLaunchRequest({ + method, + url, + payload: payload ?? undefined, + openNewTab, + launchType, + }); + + return toolLaunchRequest; } } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts index dcda4d88a86..ef5d05f39fc 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts @@ -6,14 +6,14 @@ 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 } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { OAuth2ToolLaunchStrategy } from './oauth2-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -101,4 +101,14 @@ describe('OAuth2ToolLaunchStrategy', () => { }); }); }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return oauth2', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.OAUTH2); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts index bf061ad75d5..580a40b0dbc 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { LaunchRequestMethod, PropertyData } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData } from '../../types'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -24,4 +24,8 @@ export class OAuth2ToolLaunchStrategy extends AbstractLaunchStrategy { public override determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod { return LaunchRequestMethod.GET; } + + public override determineLaunchType(): LaunchType { + return LaunchType.OAUTH2; + } } diff --git a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts deleted file mode 100644 index 5f35cafe3be..00000000000 --- a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { Authorization } from 'oauth-1.0a'; -import { Lti11EncryptionService } from './lti11-encryption.service'; - -describe('Lti11EncryptionService', () => { - let module: TestingModule; - let service: Lti11EncryptionService; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [Lti11EncryptionService], - }).compile(); - - service = module.get(Lti11EncryptionService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('sign', () => { - describe('when signing with OAuth1', () => { - const setup = () => { - const mockKey = 'mockKey'; - const mockSecret = 'mockSecret'; - const mockUrl = 'https://mockurl.com/'; - const testPayload: Record = { - param1: 'test1', - }; - - return { - mockKey, - mockSecret, - mockUrl, - testPayload, - }; - }; - - it('should sign the payload with OAuth1', () => { - const { mockKey, mockSecret, mockUrl, testPayload } = setup(); - - const result: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); - - expect(result).toEqual({ - oauth_consumer_key: mockKey, - oauth_nonce: expect.any(String), - oauth_signature: expect.any(String), - oauth_signature_method: 'HMAC-SHA1', - oauth_timestamp: expect.any(Number), - oauth_version: '1.0', - ...testPayload, - }); - }); - }); - }); -}); 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 aa333f1c186..7e66754a99f 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 @@ -12,7 +12,8 @@ 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, ToolLaunchRequest } from '../types'; +import { toolLaunchRequestFactory } from '../testing'; +import { ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, @@ -95,13 +96,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); @@ -157,13 +152,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); @@ -219,13 +208,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); diff --git a/apps/server/src/modules/tool/tool-launch/testing/index.ts b/apps/server/src/modules/tool/tool-launch/testing/index.ts new file mode 100644 index 00000000000..5063cff353c --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/testing/index.ts @@ -0,0 +1 @@ +export { toolLaunchRequestFactory } from './tool-launch-request.factory'; diff --git a/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts b/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts new file mode 100644 index 00000000000..4faed72d7b1 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts @@ -0,0 +1,13 @@ +import { Factory } from 'fishery'; +import { LaunchRequestMethod, LaunchType, ToolLaunchRequest } from '../types'; + +export const toolLaunchRequestFactory = Factory.define( + () => + new ToolLaunchRequest({ + url: 'https://example.com/tool-launch', + method: LaunchRequestMethod.GET, + payload: '{ "key": "value" }', + openNewTab: false, + launchType: LaunchType.BASIC, + }) +); 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 d58f0ad2cca..d3d294baa55 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 @@ -10,7 +10,7 @@ import { CommonToolModule } from '../common'; import { ContextExternalToolModule } from '../context-external-tool'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; -import { Lti11EncryptionService, ToolLaunchService } from './service'; +import { ToolLaunchService } from './service'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -37,7 +37,6 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat ], providers: [ ToolLaunchService, - Lti11EncryptionService, BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy, diff --git a/apps/server/src/modules/tool/tool-launch/types/index.ts b/apps/server/src/modules/tool/tool-launch/types/index.ts index 47a1fe842cf..34e79244b31 100644 --- a/apps/server/src/modules/tool/tool-launch/types/index.ts +++ b/apps/server/src/modules/tool/tool-launch/types/index.ts @@ -5,3 +5,4 @@ export * from './tool-launch-request'; export * from './tool-launch-data-type'; export * from './launch-request-method'; export * from './authentication-values'; +export * from './launch-type.enum'; diff --git a/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts b/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts new file mode 100644 index 00000000000..663fceedd92 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts @@ -0,0 +1,6 @@ +export enum LaunchType { + BASIC = 'basic', + OAUTH2 = 'oauth2', + LTI11_BASIC_LAUNCH = 'lti11BasicLaunch', + LTI11_CONTENT_ITEM_SELECTION = 'lti11ContentItemSelection', +} 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 9b7d34a130d..ee51eca6f8c 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 @@ -1,4 +1,5 @@ import { LaunchRequestMethod } from './launch-request-method'; +import { LaunchType } from './launch-type.enum'; export class ToolLaunchRequest { method: LaunchRequestMethod; @@ -9,13 +10,13 @@ export class ToolLaunchRequest { openNewTab: boolean; - isDeepLink: boolean; + launchType: LaunchType; constructor(props: ToolLaunchRequest) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; - this.isDeepLink = props.isDeepLink; + this.launchType = props.launchType; } } 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 a8b2e81e942..e83fb3afc64 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 @@ -15,7 +15,8 @@ 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, ToolLaunchRequest } from '../types'; +import { toolLaunchRequestFactory } from '../testing'; +import { ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; describe('ToolLaunchUc', () => { @@ -84,13 +85,7 @@ describe('ToolLaunchUc', () => { id: contextExternalToolId, }); - const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'baseUrl', - method: LaunchRequestMethod.GET, - payload: '', - openNewTab: true, - isDeepLink: true, - }); + const toolLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); @@ -165,13 +160,7 @@ describe('ToolLaunchUc', () => { parameters: [], }; - const toolLaunchRequest = new ToolLaunchRequest({ - openNewTab: true, - method: LaunchRequestMethod.GET, - payload: '', - url: 'https://mock.com/', - isDeepLink: false, - }); + const toolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); diff --git a/apps/server/src/shared/controller/transformer/index.ts b/apps/server/src/shared/controller/transformer/index.ts index 26b5d83fa86..889f33130c7 100644 --- a/apps/server/src/shared/controller/transformer/index.ts +++ b/apps/server/src/shared/controller/transformer/index.ts @@ -3,3 +3,4 @@ export * from './decode-html-entities.transformer'; export * from './single-value-to-array.transformer'; export * from './sanitize-html.transformer'; export { PolymorphicArrayTransform } from './polymorphic-array.transformer'; +export { StringToObject } from './string-to-object.transformer'; diff --git a/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts b/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts new file mode 100644 index 00000000000..5480c49955f --- /dev/null +++ b/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts @@ -0,0 +1,75 @@ +import { plainToClass } from 'class-transformer'; +import { StringToObject } from './index'; + +class TestObject { + string!: string; + + number!: number; + + boolean!: boolean; + + array!: Array; +} + +class Dto { + @StringToObject(TestObject) + obj!: TestObject; +} + +describe('StringToObject Decorator', () => { + describe('when transform a string to an object', () => { + const setup = () => { + const obj: TestObject = { + string: 'test', + number: 1, + boolean: true, + array: [], + }; + + const plain = { + obj: JSON.stringify(obj), + }; + + return { + obj, + plain, + }; + }; + + it('should transform a string to an object', () => { + const { obj, plain } = setup(); + + const result = plainToClass(Dto, plain); + + expect(result.obj).toEqual(obj); + }); + }); + + describe('when the object is already an object', () => { + const setup = () => { + const obj: TestObject = { + string: 'test', + number: 1, + boolean: true, + array: [], + }; + + const plain = { + obj, + }; + + return { + obj, + plain, + }; + }; + + it('should stay an object', () => { + const { obj, plain } = setup(); + + const result = plainToClass(Dto, plain); + + expect(result.obj).toEqual(obj); + }); + }); +}); diff --git a/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts b/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts new file mode 100644 index 00000000000..c40c5404fdf --- /dev/null +++ b/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts @@ -0,0 +1,15 @@ +import { ClassConstructor, plainToClass, Transform, TransformFnParams } from 'class-transformer'; + +export function StringToObject(classType: ClassConstructor): PropertyDecorator { + return Transform((params: TransformFnParams): unknown => { + if (typeof params.value === 'string') { + const res: unknown = JSON.parse(params.value); + + const obj: unknown = plainToClass(classType, res, params.options); + + return obj; + } + + return params.value; + }); +} diff --git a/apps/server/src/shared/controller/validator/index.ts b/apps/server/src/shared/controller/validator/index.ts index 711a5e04be2..e62c3e876bb 100644 --- a/apps/server/src/shared/controller/validator/index.ts +++ b/apps/server/src/shared/controller/validator/index.ts @@ -1 +1,2 @@ export * from './privacy-protect.validator'; +export { ValidateRecord } from './validate-record.validator'; diff --git a/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts b/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts new file mode 100644 index 00000000000..d7ad1d283f3 --- /dev/null +++ b/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts @@ -0,0 +1,96 @@ +import { plainToClass } from 'class-transformer'; +import { isString, validate } from 'class-validator'; +import { ValidateRecord } from './validate-record.validator'; + +class Dto { + @ValidateRecord(isString) + obj!: Record; +} + +describe('ValidateRecord Validator', () => { + describe('when the record has only valid values', () => { + const setup = () => { + const dto: Dto = { + obj: { + string1: 'string1', + string2: 'string2', + }, + }; + + return { + dto, + }; + }; + + it('should return no errors', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(0); + }); + }); + + describe('when the record has an invalid value', () => { + const setup = () => { + const dto = { + obj: { + string1: 'string1', + number1: 1, + }, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the target is not an object', () => { + const setup = () => { + const dto = { + obj: 1, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the target is null', () => { + const setup = () => { + const dto = { + obj: null, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/apps/server/src/shared/controller/validator/validate-record.validator.ts b/apps/server/src/shared/controller/validator/validate-record.validator.ts new file mode 100644 index 00000000000..33a02dfe101 --- /dev/null +++ b/apps/server/src/shared/controller/validator/validate-record.validator.ts @@ -0,0 +1,23 @@ +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +export function ValidateRecord(validationFn: (value: unknown) => boolean, validationOptions?: ValidationOptions) { + return (object: object, propertyName: string): void => { + registerDecorator({ + name: 'ValidateRecord', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown): boolean { + if (typeof value !== 'object' || value === null) { + return false; + } + return Object.values(value).every((val: unknown): boolean => validationFn(val)); + }, + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a record with valid values`; + }, + }, + }); + }; +} diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 537d17b30d5..45d69efea23 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -11,15 +11,15 @@ import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { RoomEntity } from '@modules/room/repo/entity'; -import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; -import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { ContextExternalToolEntity, LtiDeepLinkTokenEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { ImportUser } from '@modules/user-import/entity'; import { MediaSourceEntity, MediaUserLicenseEntity, UserLicenseEntity } from '@modules/user-license/entity'; +import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ColumnBoardNode } from './column-board-node.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; @@ -105,4 +105,5 @@ export const ALL_ENTITIES = [ InstanceEntity, MediaSourceEntity, OauthSessionTokenEntity, + LtiDeepLinkTokenEntity, ]; diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts index a17efc3df59..b96b2150517 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts @@ -8,6 +8,7 @@ import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/too import { contextExternalToolEntityFactory, contextExternalToolFactory, + ltiDeepLinkFactory, } from '@modules/tool/context-external-tool/testing'; import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; @@ -151,10 +152,12 @@ describe(ContextExternalToolRepo.name, () => { const result: ContextExternalTool = await repo.save(domainObject); - expect(result).toMatchObject({ - ...domainObject.getProps(), - id: expect.any(String), - }); + expect(result).toEqual( + new ContextExternalTool({ + ...domainObject.getProps(), + id: expect.any(String), + }) + ); }); }); @@ -171,6 +174,7 @@ describe(ContextExternalToolRepo.name, () => { schoolToolId: new ObjectId().toHexString(), schoolId: undefined, }, + ltiDeepLink: ltiDeepLinkFactory.build(), }); return { @@ -183,10 +187,12 @@ describe(ContextExternalToolRepo.name, () => { const result: ContextExternalTool = await repo.save(domainObject); - expect(result).toMatchObject({ - ...domainObject.getProps(), - id: expect.any(String), - }); + expect(result).toEqual( + new ContextExternalTool({ + ...domainObject.getProps(), + id: expect.any(String), + }) + ); }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index a5bd792ca85..e6165215b28 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -1,11 +1,12 @@ import { EntityName, Primary, Utils } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { ToolContextType } from '@modules/tool/common/enum/tool-context-type.enum'; -import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalTool, ContextRef, LtiDeepLink } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity, ContextExternalToolEntityProps, ContextExternalToolType, + LtiDeepLinkEmbeddable, } from '@modules/tool/context-external-tool/entity'; import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; import { SchoolExternalToolRef } from '@modules/tool/school-external-tool/domain'; @@ -140,22 +141,55 @@ export class ContextExternalToolRepo { type: this.mapContextTypeToDomainObjectType(entity.contextType), }); + const ltiDeepLinkEntity: LtiDeepLinkEmbeddable | undefined = entity.ltiDeepLink; + const ltiDeepLink: LtiDeepLink | undefined = ltiDeepLinkEntity + ? new LtiDeepLink({ + mediaType: ltiDeepLinkEntity.mediaType, + url: ltiDeepLinkEntity.url, + title: ltiDeepLinkEntity.title, + text: ltiDeepLinkEntity.text, + parameters: ExternalToolRepoMapper.mapCustomParameterEntryEntitiesToDOs(ltiDeepLinkEntity.parameters), + availableFrom: ltiDeepLinkEntity.availableFrom, + availableUntil: ltiDeepLinkEntity.availableUntil, + submissionFrom: ltiDeepLinkEntity.submissionFrom, + submissionUntil: ltiDeepLinkEntity.submissionUntil, + }) + : undefined; + return new ContextExternalTool({ id: entity.id, schoolToolRef, contextRef, displayName: entity.displayName, parameters: ExternalToolRepoMapper.mapCustomParameterEntryEntitiesToDOs(entity.parameters), + ltiDeepLink, }); } private mapDomainObjectToEntityProps(entityDO: ContextExternalTool): ContextExternalToolEntityProps { + const { ltiDeepLink } = entityDO; + + const ltiDeepLinkEntity: LtiDeepLinkEmbeddable | undefined = ltiDeepLink + ? new LtiDeepLinkEmbeddable({ + mediaType: ltiDeepLink.mediaType, + url: ltiDeepLink.url, + title: ltiDeepLink.title, + text: ltiDeepLink.text, + parameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(ltiDeepLink.parameters), + availableFrom: ltiDeepLink.availableFrom, + availableUntil: ltiDeepLink.availableUntil, + submissionFrom: ltiDeepLink.submissionFrom, + submissionUntil: ltiDeepLink.submissionUntil, + }) + : undefined; + return { contextId: entityDO.contextRef.id, contextType: this.mapContextTypeToEntityType(entityDO.contextRef.type), displayName: entityDO.displayName, schoolTool: this.em.getReference(SchoolExternalToolEntity, entityDO.schoolToolRef.schoolToolId), parameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(entityDO.parameters), + ltiDeepLink: ltiDeepLinkEntity, }; } diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts index 88f57335a34..d538e13efed 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts @@ -56,8 +56,12 @@ describe(ExternalToolRepo.name, () => { const client2Id = 'client-2'; const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.withBasicConfig().buildWithId(); - const externalOauthTool: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-1').buildWithId(); - const externalOauthTool2: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-2').buildWithId(); + const externalOauthTool: ExternalToolEntity = externalToolEntityFactory + .withOauth2Config({ clientId: 'client-1' }) + .buildWithId(); + const externalOauthTool2: ExternalToolEntity = externalToolEntityFactory + .withOauth2Config({ clientId: 'client-2' }) + .buildWithId(); const externalLti11Tool: ExternalToolEntity = externalToolEntityFactory.withLti11Config().buildWithId(); await em.persistAndFlush([externalToolEntity, externalOauthTool, externalOauthTool2, externalLti11Tool]); diff --git a/apps/server/src/shared/testing/date-to-string.ts b/apps/server/src/shared/testing/date-to-string.ts new file mode 100644 index 00000000000..ecdfaea67bb --- /dev/null +++ b/apps/server/src/shared/testing/date-to-string.ts @@ -0,0 +1 @@ +export type DateToString = T extends Date ? string : T extends object ? { [K in keyof T]: DateToString } : T; diff --git a/apps/server/src/shared/testing/dates-to-strings.ts b/apps/server/src/shared/testing/dates-to-strings.ts deleted file mode 100644 index 96cc18d2c34..00000000000 --- a/apps/server/src/shared/testing/dates-to-strings.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type DatesToStrings = { - [k in keyof T]: T[k] extends Date ? string : DatesToStrings; -}; diff --git a/apps/server/src/shared/testing/index.ts b/apps/server/src/shared/testing/index.ts index 5615f70b563..c891be689a6 100644 --- a/apps/server/src/shared/testing/index.ts +++ b/apps/server/src/shared/testing/index.ts @@ -5,4 +5,4 @@ export * from './cleanup-collections'; export * from './map-user-to-current-user'; export * from './test-api-client'; export * from './web-socket-ready-state-enum'; -export { DatesToStrings } from './dates-to-strings'; +export { DateToString } from './date-to-string'; diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 9dfb704a35b..68da38a66ab 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -1126,8 +1126,8 @@ "parameters": [], "isHidden": false, "isDeactivated": false, - "openNewTab": false, - "restrictToContexts": [], + "openNewTab": true, + "restrictToContexts": ["board-element", "course"], "isPreferred": true, "iconName": "mdiMovieRoll" },