diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index c042be2c2a9..a5688568696 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -172,7 +172,7 @@ jobs: uses: github/codeql-action/upload-sarif@v2 with: sarif_file: 'trivy-results.sarif' - + end-to-end-tests: needs: - build_and_push diff --git a/ansible/roles/schulcloud-server-h5p/tasks/main.yml b/ansible/roles/schulcloud-server-h5p/tasks/main.yml index 368e97a216e..0cb4feff19c 100644 --- a/ansible/roles/schulcloud-server-h5p/tasks/main.yml +++ b/ansible/roles/schulcloud-server-h5p/tasks/main.yml @@ -1,4 +1,4 @@ - - name: H5pEditorService + - name: H5PEditorProvider kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" diff --git a/apps/server/src/apps/h5p-editor.app.ts b/apps/server/src/apps/h5p-editor.app.ts index 518eb5c45bc..c25f9156be6 100644 --- a/apps/server/src/apps/h5p-editor.app.ts +++ b/apps/server/src/apps/h5p-editor.app.ts @@ -19,6 +19,7 @@ async function bootstrap() { const nestExpress = express(); const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(H5PEditorModule, nestExpressAdapter); // WinstonLogger nestApp.useLogger(await nestApp.resolve(LegacyLogger)); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-ajax.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-ajax.api.spec.ts new file mode 100644 index 00000000000..543db38ebbf --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-ajax.api.spec.ts @@ -0,0 +1,178 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PAjaxEndpoint } from '@lumieducation/h5p-server'; +import { EntityManager } from '@mikro-orm/core'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + let ajaxEndpoint: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5PAjaxEndpoint) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + ajaxEndpoint = app.get(H5PAjaxEndpoint); + testApiClient = new TestApiClient(app, 'h5p-editor'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when calling AJAX GET', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get('ajax'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser }; + }; + + it('should call H5PAjaxEndpoint', async () => { + const { + loggedInClient, + studentUser: { id }, + } = await setup(); + + const dummyResponse = { + apiVersion: { major: 1, minor: 1 }, + details: [], + libraries: [], + outdated: false, + recentlyUsed: [], + user: 'DummyUser', + }; + + ajaxEndpoint.getAjax.mockResolvedValueOnce(dummyResponse); + + const response = await loggedInClient.get(`ajax?action=content-type-cache`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(dummyResponse); + expect(ajaxEndpoint.getAjax).toHaveBeenCalledWith( + 'content-type-cache', + undefined, // MachineName + undefined, // MajorVersion + undefined, // MinorVersion + 'de', // Language + expect.objectContaining({ id }) + ); + }); + }); + + describe('when calling AJAX POST', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.post('ajax'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser }; + }; + + it('should call H5PAjaxEndpoint', async () => { + const { + loggedInClient, + studentUser: { id }, + } = await setup(); + + const dummyResponse = [ + { + majorVersion: 1, + minorVersion: 2, + metadataSettings: {}, + name: 'Dummy Library', + restricted: false, + runnable: true, + title: 'Dummy Library', + tutorialUrl: '', + uberName: 'dummyLibrary-1.1', + }, + ]; + + const dummyBody = { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' }; + + ajaxEndpoint.postAjax.mockResolvedValueOnce(dummyResponse); + + const response = await loggedInClient.post(`ajax?action=libraries`, dummyBody); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual(dummyResponse); + expect(ajaxEndpoint.postAjax).toHaveBeenCalledWith( + 'libraries', + dummyBody, + 'de', + expect.objectContaining({ id }), + undefined, + undefined, + undefined, + undefined, + undefined + ); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts new file mode 100644 index 00000000000..9f3b0017d08 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts @@ -0,0 +1,106 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { Request } from 'express'; +import request from 'supertest'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; +import { H5PEditorUc } from '../../uc/h5p.uc'; + +class API { + constructor(private app: INestApplication) { + this.app = app; + } + + async deleteH5pContent(contentId: string) { + return request(this.app.getHttpServer()).post(`/h5p-editor/delete/${contentId}`); + } +} + +const setup = () => { + const contentId = new ObjectId(0).toString(); + const notExistingContentId = new ObjectId(1).toString(); + const badContentId = ''; + + return { contentId, notExistingContentId, badContentId }; +}; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let api: API; + let em: EntityManager; + let currentUser: ICurrentUser; + let h5PEditorUc: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5PEditorUc) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + h5PEditorUc = module.get(H5PEditorUc); + + api = new API(app); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('delete h5p content', () => { + beforeEach(async () => { + await cleanupCollections(em); + const school = schoolFactory.build(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + }); + const user = userFactory.build({ school, roles }); + + await em.persistAndFlush([user, school]); + em.clear(); + + currentUser = mapUserToCurrentUser(user); + }); + describe('with valid request params', () => { + it('should return 200 status', async () => { + const { contentId } = setup(); + + h5PEditorUc.deleteH5pContent.mockResolvedValueOnce(true); + const response = await api.deleteH5pContent(contentId); + expect(response.status).toEqual(201); + }); + }); + describe('with bad request params', () => { + it('should return 500 status', async () => { + const { notExistingContentId } = setup(); + + h5PEditorUc.deleteH5pContent.mockRejectedValueOnce(new Error('Could not delete H5P content')); + const response = await api.deleteH5pContent(notExistingContentId); + expect(response.status).toEqual(500); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts new file mode 100644 index 00000000000..702aac1a717 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts @@ -0,0 +1,381 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ILibraryName } from '@lumieducation/h5p-server'; +import { ContentMetadata } from '@lumieducation/h5p-server/build/src/ContentMetadata'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { + courseFactory, + h5pContentFactory, + lessonFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { ObjectID } from 'bson'; +import { Readable } from 'stream'; +import { H5PContent, H5PContentParentType, IH5PContentProperties, H5pEditorTempFile } from '../../entity'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; +import { ContentStorage, LibraryStorage, TemporaryFileStorage } from '../../service'; + +const helpers = { + buildMetadata( + title: string, + mainLibrary: string, + preloadedDependencies: ILibraryName[] = [], + dynamicDependencies?: ILibraryName[], + editorDependencies?: ILibraryName[] + ): ContentMetadata { + return { + defaultLanguage: 'de-DE', + license: 'Unlicensed', + title, + dynamicDependencies, + editorDependencies, + embedTypes: ['iframe'], + language: 'de-DE', + mainLibrary, + preloadedDependencies, + }; + }, + + buildContent(n = 0) { + const metadata = helpers.buildMetadata(`Content #${n}`, `Library-${n}.0`); + const content = { + data: `Data #${n}`, + }; + const h5pContentProperties: IH5PContentProperties = { + creatorId: new ObjectID().toString(), + parentId: new ObjectID().toString(), + schoolId: new ObjectID().toString(), + metadata, + content, + parentType: H5PContentParentType.Lesson, + }; + const h5pContent = new H5PContent(h5pContentProperties); + + return { + withID(id?: number) { + const objectId = new ObjectID(id); + h5pContent._id = objectId; + h5pContent.id = objectId.toString(); + + return h5pContent; + }, + new() { + return h5pContent; + }, + }; + }, +}; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + let contentStorage: DeepMocked; + let libraryStorage: DeepMocked; + let temporaryStorage: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(ContentStorage) + .useValue(createMock()) + .overrideProvider(LibraryStorage) + .useValue(createMock()) + .overrideProvider(TemporaryFileStorage) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + contentStorage = app.get(ContentStorage); + libraryStorage = app.get(LibraryStorage); + temporaryStorage = app.get(TemporaryFileStorage); + testApiClient = new TestApiClient(app, 'h5p-editor'); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when requesting library files', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get('libraries/dummyLib/test.txt'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return the library file', async () => { + const { loggedInClient } = await setup(); + + const mockFile = { content: 'Test File', size: 9, name: 'test.txt', birthtime: new Date() }; + + libraryStorage.getLibraryFile.mockResolvedValueOnce({ + stream: Readable.from(mockFile.content), + size: mockFile.size, + mimetype: 'text/plain', + }); + + const response = await loggedInClient.get(`libraries/dummyLib-1.0/${mockFile.name}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.text).toBe(mockFile.content); + }); + + it('should return 404 if file does not exist', async () => { + const { loggedInClient } = await setup(); + + libraryStorage.getLibraryFile.mockRejectedValueOnce(new Error('Does not exist')); + + const response = await loggedInClient.get(`libraries/dummyLib-1.0/nonexistant.txt`); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); + + describe('when requesting content files', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get('content/dummyId/test.txt'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + const course = courseFactory.build({ students: [studentUser], school: studentUser.school }); + const lesson = lessonFactory.build({ course }); + await em.persistAndFlush([studentAccount, studentUser, lesson, course]); + + const content = h5pContentFactory.build({ parentId: lesson.id, parentType: H5PContentParentType.Lesson }); + await em.persistAndFlush([content]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, content }; + }; + + it('should return the content file', async () => { + const { loggedInClient, content } = await setup(); + + const mockFile = { content: 'Test File', size: 9, name: 'test.txt', birthtime: new Date() }; + + contentStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); + contentStorage.getFileStats.mockResolvedValueOnce({ birthtime: mockFile.birthtime, size: mockFile.size }); + + const response = await loggedInClient.get(`content/${content.id}/${mockFile.name}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.text).toBe(mockFile.content); + }); + + it('should work with range requests', async () => { + const { loggedInClient, content } = await setup(); + + const mockFile = { content: 'Test File', size: 9, name: 'test.txt', birthtime: new Date() }; + + contentStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); + contentStorage.getFileStats.mockResolvedValueOnce({ birthtime: mockFile.birthtime, size: mockFile.size }); + + const response = await loggedInClient.get(`content/${content.id}/${mockFile.name}`).set('Range', 'bytes=2-4'); + + expect(response.statusCode).toEqual(HttpStatus.PARTIAL_CONTENT); + expect(response.text).toBe(mockFile.content); + }); + + it('should return 404 if file does not exist', async () => { + const { loggedInClient, content } = await setup(); + + contentStorage.getFileStats.mockRejectedValueOnce(new Error('Does not exist')); + + const response = await loggedInClient.get(`content/${content.id}/nonexistant.txt`); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); + + describe('when requesting temporary files', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get('temp-files/test.txt'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const mockFile = { + name: 'example.txt', + content: 'File Content', + }; + + const mockTempFile = new H5pEditorTempFile({ + filename: mockFile.name, + ownedByUserId: studentUser.id, + expiresAt: new Date(), + birthtime: new Date(), + size: mockFile.content.length, + }); + + return { loggedInClient, mockFile, mockTempFile }; + }; + + it('should return the content file', async () => { + const { loggedInClient, mockFile, mockTempFile } = await setup(); + + temporaryStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); + temporaryStorage.getFileStats.mockResolvedValueOnce(mockTempFile); + + const response = await loggedInClient.get(`temp-files/${mockFile.name}`); + + expect(response.statusCode).toEqual(HttpStatus.PARTIAL_CONTENT); + expect(response.text).toBe(mockFile.content); + }); + + it('should work with range requests', async () => { + const { loggedInClient, mockFile, mockTempFile } = await setup(); + + temporaryStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); + temporaryStorage.getFileStats.mockResolvedValueOnce(mockTempFile); + + const response = await loggedInClient.get(`temp-files/${mockFile.name}`).set('Range', 'bytes=2-4'); + + expect(response.statusCode).toEqual(HttpStatus.PARTIAL_CONTENT); + expect(response.text).toBe(mockFile.content); + }); + + it('should return 404 if file does not exist', async () => { + const { loggedInClient } = await setup(); + + temporaryStorage.getFileStats.mockRejectedValueOnce(new Error('Does not exist')); + + const response = await loggedInClient.get(`temp-files/nonexistant.txt`); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); + + describe('when requesting content parameters', () => { + describe('when user not exists', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get('params/dummyId'); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when user is logged in', () => { + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + + const setup = async () => { + const { studentAccount, studentUser } = createStudent(); + const course = courseFactory.build({ students: [studentUser], school: studentUser.school }); + const lesson = lessonFactory.build({ course }); + await em.persistAndFlush([studentAccount, studentUser, lesson, course]); + + const content = h5pContentFactory.build({ parentId: lesson.id, parentType: H5PContentParentType.Lesson }); + await em.persistAndFlush([content]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, content }; + }; + + it('should return the content parameters', async () => { + const { loggedInClient, content } = await setup(); + + const dummyMetadata = new ContentMetadata(); + const dummyParams = { name: 'Dummy' }; + + contentStorage.getMetadata.mockResolvedValueOnce(dummyMetadata); + contentStorage.getParameters.mockResolvedValueOnce(dummyParams); + const response = await loggedInClient.get(`params/${content.id}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + h5p: dummyMetadata, + params: { metadata: dummyMetadata, params: dummyParams }, + }); + }); + + it('should return 404 if content does not exist', async () => { + const { loggedInClient } = await setup(); + + contentStorage.getMetadata.mockRejectedValueOnce(new Error('Does not exist')); + + const response = await loggedInClient.get('params/dummyId'); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts new file mode 100644 index 00000000000..737266f300d --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts @@ -0,0 +1,155 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { Request } from 'express'; +import request from 'supertest'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; +import { H5PEditorUc } from '../../uc/h5p.uc'; + +class API { + constructor(private app: INestApplication) { + this.app = app; + } + + async emptyEditor() { + return request(this.app.getHttpServer()).get(`/h5p-editor/edit/de`); + } + + async editH5pContent(contentId: string) { + return request(this.app.getHttpServer()).get(`/h5p-editor/edit/${contentId}/de`); + } +} + +const setup = () => { + const contentId = new ObjectId(0).toString(); + const notExistingContentId = new ObjectId(1).toString(); + const badContentId = ''; + + const editorModel = { + scripts: ['example.js'], + styles: ['example.css'], + }; + + const exampleContent = { + h5p: {}, + library: 'ExampleLib-1.0', + params: { + metadata: {}, + params: { anything: true }, + }, + }; + + return { contentId, notExistingContentId, badContentId, editorModel, exampleContent }; +}; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let api: API; + let em: EntityManager; + let currentUser: ICurrentUser; + let h5PEditorUc: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5PEditorUc) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + h5PEditorUc = module.get(H5PEditorUc); + + api = new API(app); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('get new h5p editor', () => { + beforeEach(async () => { + await cleanupCollections(em); + const school = schoolFactory.build(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + }); + const user = userFactory.build({ school, roles }); + + await em.persistAndFlush([user, school]); + em.clear(); + + currentUser = mapUserToCurrentUser(user); + }); + describe('with valid request params', () => { + it('should return 200 status', async () => { + const { editorModel } = setup(); + // @ts-expect-error partial object + h5PEditorUc.getEmptyH5pEditor.mockResolvedValueOnce(editorModel); + const response = await api.emptyEditor(); + expect(response.status).toEqual(200); + }); + }); + describe('with bad request params', () => { + it('should return 500 status', async () => { + h5PEditorUc.getEmptyH5pEditor.mockRejectedValueOnce(new Error('Could not get H5P editor')); + const response = await api.emptyEditor(); + expect(response.status).toEqual(500); + }); + }); + }); + + describe('get h5p editor', () => { + beforeEach(async () => { + await cleanupCollections(em); + const school = schoolFactory.build(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + }); + const user = userFactory.build({ school, roles }); + + await em.persistAndFlush([user, school]); + em.clear(); + + currentUser = mapUserToCurrentUser(user); + }); + describe('with valid request params', () => { + it('should return 200 status', async () => { + const { contentId, editorModel, exampleContent } = setup(); + // @ts-expect-error partial object + h5PEditorUc.getH5pEditor.mockResolvedValueOnce({ editorModel, content: exampleContent }); + const response = await api.editH5pContent(contentId); + expect(response.status).toEqual(200); + }); + }); + describe('with bad request params', () => { + it('should return 500 status', async () => { + const { notExistingContentId } = setup(); + h5PEditorUc.getH5pEditor.mockRejectedValueOnce(new Error('Could not get H5P editor')); + const response = await api.editH5pContent(notExistingContentId); + expect(response.status).toEqual(500); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts new file mode 100644 index 00000000000..708dfef968a --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts @@ -0,0 +1,114 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { IPlayerModel } from '@lumieducation/h5p-server'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { Request } from 'express'; +import request from 'supertest'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; +import { H5PEditorUc } from '../../uc/h5p.uc'; + +class API { + constructor(private app: INestApplication) { + this.app = app; + } + + async getPlayer(contentId: string) { + return request(this.app.getHttpServer()).get(`/h5p-editor/play/${contentId}`); + } +} + +const setup = () => { + const contentId = new ObjectId(0).toString(); + const notExistingContentId = new ObjectId(1).toString(); + + // @ts-expect-error partial object + const playerResult: IPlayerModel = { + contentId, + dependencies: [], + downloadPath: '', + embedTypes: ['iframe'], + scripts: ['example.js'], + styles: ['example.css'], + }; + + return { contentId, notExistingContentId, playerResult }; +}; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let api: API; + let em: EntityManager; + let currentUser: ICurrentUser; + let h5PEditorUc: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5PEditorUc) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + h5PEditorUc = module.get(H5PEditorUc); + await app.init(); + + api = new API(app); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('get h5p player', () => { + beforeEach(async () => { + await cleanupCollections(em); + const school = schoolFactory.build(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + }); + const user = userFactory.build({ school, roles }); + + await em.persistAndFlush([user, school]); + em.clear(); + + currentUser = mapUserToCurrentUser(user); + }); + describe('with valid request params', () => { + it('should return 200 status', async () => { + const { contentId, playerResult } = setup(); + h5PEditorUc.getH5pPlayer.mockResolvedValueOnce(playerResult); + const response = await api.getPlayer(contentId); + expect(response.status).toEqual(200); + }); + }); + describe('with bad request params', () => { + it('should return 500 status', async () => { + const { notExistingContentId } = setup(); + h5PEditorUc.getH5pPlayer.mockRejectedValueOnce(new Error('Could not get H5P player')); + const response = await api.getPlayer(notExistingContentId); + expect(response.status).toEqual(500); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-save-create.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-save-create.api.spec.ts new file mode 100644 index 00000000000..9981a13e81f --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-save-create.api.spec.ts @@ -0,0 +1,176 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { IContentMetadata } from '@lumieducation/h5p-server'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { H5PContentParentType } from '../../entity'; +import { H5PEditorTestModule } from '../../h5p-editor-test.module'; +import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; +import { H5PEditorUc } from '../../uc/h5p.uc'; +import { PostH5PContentCreateParams } from '../dto'; + +describe('H5PEditor Controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let h5PEditorUc: DeepMocked; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [H5PEditorTestModule], + }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(H5PEditorUc) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + h5PEditorUc = module.get(H5PEditorUc); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, 'h5p-editor'); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('create h5p content', () => { + describe('with valid request params', () => { + const setup = async () => { + const id = '0000000'; + const metadata: IContentMetadata = { + embedTypes: [], + language: 'de', + mainLibrary: 'mainLib', + preloadedDependencies: [], + defaultLanguage: '', + license: '', + title: '123', + }; + const params: PostH5PContentCreateParams = { + parentType: H5PContentParentType.Lesson, + parentId: new ObjectId().toString(), + params: { + params: undefined, + metadata: { + embedTypes: [], + language: '', + mainLibrary: '', + preloadedDependencies: [], + defaultLanguage: '', + license: '', + title: '', + }, + }, + library: '123', + }; + + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + const { studentAccount, studentUser } = createStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + const loggedInClient = await testApiClient.login(studentAccount); + + return { id, metadata, loggedInClient, params }; + }; + it('should return 201 status', async () => { + const { id, metadata, loggedInClient, params } = await setup(); + const result1 = { id, metadata }; + h5PEditorUc.createH5pContentGetMetadata.mockResolvedValueOnce(result1); + const response = await loggedInClient.post(`/edit`, params); + expect(response.status).toEqual(201); + }); + }); + }); + describe('save h5p content', () => { + describe('with valid request params', () => { + const setup = async () => { + const contentId = new ObjectId(0); + const id = '0000000'; + const metadata: IContentMetadata = { + embedTypes: [], + language: 'de', + mainLibrary: 'mainLib', + preloadedDependencies: [], + defaultLanguage: '', + license: '', + title: '123', + }; + const params: PostH5PContentCreateParams = { + parentType: H5PContentParentType.Lesson, + parentId: new ObjectId().toString(), + params: { + params: undefined, + metadata: { + embedTypes: [], + language: '', + mainLibrary: '', + preloadedDependencies: [], + defaultLanguage: '', + license: '', + title: '', + }, + }, + library: '123', + }; + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + const { studentAccount, studentUser } = createStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + const loggedInClient = await testApiClient.login(studentAccount); + + return { contentId, id, metadata, loggedInClient, params }; + }; + it('should return 201 status', async () => { + const { contentId, id, metadata, loggedInClient, params } = await setup(); + const result1 = { id, metadata }; + h5PEditorUc.saveH5pContentGetMetadata.mockResolvedValueOnce(result1); + const response = await loggedInClient.post(`/edit/${contentId.toString()}`, params); + + expect(response.status).toEqual(201); + }); + }); + describe('with bad request params', () => { + const setup = async () => { + const notExistingContentId = new ObjectId(1); + const params: PostH5PContentCreateParams = { + parentType: H5PContentParentType.Lesson, + parentId: new ObjectId().toString(), + params: { + params: undefined, + metadata: { + embedTypes: [], + language: '', + mainLibrary: '', + preloadedDependencies: [], + defaultLanguage: '', + license: '', + title: '', + }, + }, + library: '123', + }; + const createStudent = () => UserAndAccountTestFactory.buildStudent(); + const { studentAccount, studentUser } = createStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + const loggedInClient = await testApiClient.login(studentAccount); + + return { notExistingContentId, loggedInClient, params }; + }; + it('should return 500 status', async () => { + const { notExistingContentId, loggedInClient, params } = await setup(); + h5PEditorUc.saveH5pContentGetMetadata.mockRejectedValueOnce(new Error('Could not save H5P content')); + const response = await loggedInClient.post(`/edit/${notExistingContentId.toString()}`, params); + + expect(response.status).toEqual(500); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor.api.spec.ts deleted file mode 100644 index 57a8a66b347..00000000000 --- a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor.api.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { EntityManager } from '@mikro-orm/core'; -import { HttpStatus, INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; -import { H5PEditorTestModule } from '@modules/h5p-editor/h5p-editor-test.module'; - -describe('H5PEditor Controller (api)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - - beforeAll(async () => { - const module = await Test.createTestingModule({ - imports: [H5PEditorTestModule], - }).compile(); - - app = module.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - testApiClient = new TestApiClient(app, 'h5p-editor'); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('get player', () => { - describe('when user not exists', () => { - it('should respond with unauthorized exception', async () => { - const response = await testApiClient.get('dummyID/play'); - - expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); - expect(response.body).toEqual({ - type: 'UNAUTHORIZED', - title: 'Unauthorized', - message: 'Unauthorized', - code: 401, - }); - }); - }); - - describe('when user is allowed to view player', () => { - const createStudent = () => UserAndAccountTestFactory.buildStudent(); - - const setup = async () => { - const { studentAccount, studentUser } = createStudent(); - - await em.persistAndFlush([studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { loggedInClient }; - }; - - it('should return the player', async () => { - const { loggedInClient } = await setup(); - - const response = await loggedInClient.get('dummyID/play'); - - expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.text).toContain('

H5P Player Dummy

'); - }); - }); - }); - - describe('get editor', () => { - describe('when user not exists', () => { - it('should respond with unauthorized exception', async () => { - const response = await testApiClient.get('dummyID/edit'); - - expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); - expect(response.body).toEqual({ - type: 'UNAUTHORIZED', - title: 'Unauthorized', - message: 'Unauthorized', - code: 401, - }); - }); - }); - - describe('when user is allowed to view editor', () => { - const createStudent = () => UserAndAccountTestFactory.buildStudent(); - - const setup = async () => { - const { studentAccount, studentUser } = createStudent(); - - await em.persistAndFlush([studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { loggedInClient }; - }; - - it('should return the editor', async () => { - const { loggedInClient } = await setup(); - - const response = await loggedInClient.get('dummyID/edit'); - - expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.text).toContain('

H5P Editor Dummy

'); - }); - }); - }); -}); diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/get.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/get.params.ts new file mode 100644 index 00000000000..50695c28b75 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/get.params.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class AjaxGetQueryParams { + @IsString() + @IsNotEmpty() + action!: string; + + @IsString() + @IsOptional() + machineName?: string; + + @IsString() + @IsOptional() + majorVersion?: string; + + @IsString() + @IsOptional() + minorVersion?: string; + + @IsString() + @IsOptional() + language?: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/index.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/index.ts new file mode 100644 index 00000000000..3410511d0cc --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/index.ts @@ -0,0 +1,3 @@ +export * from './get.params'; +export * from './post.body.params'; +export * from './post.params'; diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts new file mode 100644 index 00000000000..8db32320969 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + AjaxPostBodyParams, + ContentBodyParams, + LibrariesBodyParams, + LibraryParametersBodyParams, +} from './post.body.params'; +import { AjaxPostBodyParamsTransformPipe } from './post.body.params.transform-pipe'; + +jest.mock('@nestjs/common', () => { + return { + ...jest.requireActual('@nestjs/common'), + ValidationPipe: jest.fn().mockImplementation(() => { + return { + transform: jest.fn(), + createExceptionFactory: jest.fn(() => jest.fn(() => new Error('Mocked Error'))), + }; + }), + }; +}); + +describe('transform', () => { + let ajaxBodyTransformPipe: AjaxPostBodyParamsTransformPipe; + let emptyAjaxPostBodyParams1: AjaxPostBodyParams; + let emptyAjaxPostBodyParams2: AjaxPostBodyParams; + let emptyAjaxPostBodyParams3: AjaxPostBodyParams; + let emptyAjaxPostBodyParams4: AjaxPostBodyParams; + + let module: TestingModule; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let validationPipe: ValidationPipe; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [AjaxPostBodyParamsTransformPipe, ValidationPipe], + }).compile(); + validationPipe = module.get(ValidationPipe); + ajaxBodyTransformPipe = module.get(AjaxPostBodyParamsTransformPipe); + + const emptyLibrariesBodyParams: LibrariesBodyParams = { + libraries: [], + }; + + const emptyLibraryParametersBodyParams: LibraryParametersBodyParams = { + libraryParameters: '', + }; + + const emptyContentBodyParams: ContentBodyParams = { + contentId: '', + field: '', + }; + + emptyAjaxPostBodyParams1 = emptyLibrariesBodyParams; + emptyAjaxPostBodyParams2 = emptyContentBodyParams; + emptyAjaxPostBodyParams3 = emptyLibraryParametersBodyParams; + emptyAjaxPostBodyParams4 = undefined; + }); + + it('when libaries in value', async () => { + const result = await ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams1); + expect(result).toBeDefined(); + }); + + it('when contentId in value', async () => { + await expect(ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams2)).rejects.toThrowError('Mocked Error'); + }); + + it('when libaryParameters in value', async () => { + const result = await ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams3); + expect(result).toBeDefined(); + }); + + it('when not libaries | contentId | libaryParameters in value', async () => { + const result = await ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams4); + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.ts new file mode 100644 index 00000000000..f4ab7c18e69 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.ts @@ -0,0 +1,39 @@ +import { Injectable, PipeTransform, ValidationPipe } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + AjaxPostBodyParams, + LibrariesBodyParams, + ContentBodyParams, + LibraryParametersBodyParams, +} from './post.body.params'; + +/** + * This transform pipe allows nest to validate the incoming request. + * Since H5P does sent bodies with different shapes, this custom ValidationPipe makes sure the different cases are correctly validated. + */ + +@Injectable() +export class AjaxPostBodyParamsTransformPipe implements PipeTransform { + async transform(value: AjaxPostBodyParams): Promise { + if (value === undefined) { + return undefined; + } + if ('libraries' in value) { + value = plainToClass(LibrariesBodyParams, value); + } else if ('contentId' in value) { + value = plainToClass(ContentBodyParams, value); + } else if ('libraryParameters' in value) { + value = plainToClass(LibraryParametersBodyParams, value); + } + + const validationResult = await validate(value); + if (validationResult.length > 0) { + const validationPipe = new ValidationPipe(); + const exceptionFactory = validationPipe.createExceptionFactory(); + throw exceptionFactory(validationResult); + } + + return value; + } +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts new file mode 100644 index 00000000000..496616810c6 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsMongoId, IsOptional, IsString } from 'class-validator'; + +export class LibrariesBodyParams { + @ApiProperty() + @IsArray() + @IsString({ each: true }) + libraries!: string[]; +} + +export class ContentBodyParams { + @ApiProperty() + @IsMongoId() + contentId!: string; + + @ApiProperty() + @IsString() + @IsOptional() + field!: string; +} + +export class LibraryParametersBodyParams { + @ApiProperty() + @IsString() + libraryParameters!: string; +} + +export type AjaxPostBodyParams = LibrariesBodyParams | ContentBodyParams | LibraryParametersBodyParams | undefined; diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.params.ts new file mode 100644 index 00000000000..b84dc984504 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.params.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class AjaxPostQueryParams { + @IsString() + @IsNotEmpty() + action!: string; + + @IsString() + @IsOptional() + machineName?: string; + + @IsString() + @IsOptional() + majorVersion?: string; + + @IsString() + @IsOptional() + minorVersion?: string; + + @IsString() + @IsOptional() + language?: string; + + @IsString() + @IsOptional() + id?: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/content-file.url.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/content-file.url.params.ts new file mode 100644 index 00000000000..e8e4b404faa --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/content-file.url.params.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId, IsNotEmpty, IsString } from 'class-validator'; + +export class ContentFileUrlParams { + @ApiProperty() + @IsMongoId() + id!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + filename!: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor-response.spec.ts b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor-response.spec.ts new file mode 100644 index 00000000000..451efaeca89 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor-response.spec.ts @@ -0,0 +1,26 @@ +import { IContentMetadata } from '@lumieducation/h5p-server/build/src/types'; +import { H5PContentMetadata } from './h5p-editor.response'; + +describe('H5PContentMetadata', () => { + let h5pContentMetadata: H5PContentMetadata; + + beforeEach(() => { + const testContentMetadata: IContentMetadata = { + embedTypes: ['iframe'], + language: 'en', + mainLibrary: 'testLibrary', + preloadedDependencies: [ + { machineName: 'Dependency1', majorVersion: 1, minorVersion: 0 }, + { machineName: 'Dependency2', majorVersion: 2, minorVersion: 0 }, + ], + defaultLanguage: '', + license: '', + title: '', + }; + h5pContentMetadata = new H5PContentMetadata(testContentMetadata); + }); + + it('should be defined', () => { + expect(h5pContentMetadata).toBeDefined(); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts new file mode 100644 index 00000000000..a8c6d8c466d --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts @@ -0,0 +1,82 @@ +import { IContentMetadata } from '@lumieducation/h5p-server'; +import { ApiProperty } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { EntityId, LanguageType } from '@shared/domain'; +import { IsEnum, IsMongoId, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import { H5PContentParentType } from '../../entity'; + +export class GetH5PContentParams { + @ApiProperty({ enum: LanguageType, enumName: 'LanguageType' }) + @IsEnum(LanguageType) + @IsOptional() + language?: LanguageType; + + @ApiProperty() + @IsMongoId() + contentId!: string; +} + +export class GetH5PEditorParamsCreate { + @ApiProperty({ enum: LanguageType, enumName: 'LanguageType' }) + @IsEnum(LanguageType) + language!: LanguageType; +} + +export class GetH5PEditorParams { + @ApiProperty() + @IsMongoId() + contentId!: string; + + @ApiProperty({ enum: LanguageType, enumName: 'LanguageType' }) + @IsEnum(LanguageType) + language!: LanguageType; +} + +export class SaveH5PEditorParams { + @ApiProperty() + @IsMongoId() + contentId!: string; +} + +export class PostH5PContentParams { + @ApiProperty() + @IsMongoId() + contentId!: string; + + @ApiProperty() + @IsNotEmpty() + params!: unknown; + + @ApiProperty() + @IsNotEmpty() + metadata!: IContentMetadata; + + @ApiProperty() + @IsString() + @SanitizeHtml() + @IsNotEmpty() + mainLibraryUbername!: string; +} + +export class PostH5PContentCreateParams { + @ApiProperty({ enum: H5PContentParentType, enumName: 'H5PContentParentType' }) + @IsEnum(H5PContentParentType) + parentType!: H5PContentParentType; + + @ApiProperty() + @IsMongoId() + parentId!: EntityId; + + @ApiProperty() + @IsNotEmpty() + @IsObject() + params!: { + params: unknown; + metadata: IContentMetadata; + }; + + @ApiProperty() + @IsString() + @IsNotEmpty() + library!: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.response.ts b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.response.ts new file mode 100644 index 00000000000..b76f247fc8e --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.response.ts @@ -0,0 +1,94 @@ +import { ContentParameters, IContentMetadata, IEditorModel, IIntegration } from '@lumieducation/h5p-server'; +import { ApiProperty } from '@nestjs/swagger'; +import { Readable } from 'stream'; + +export class H5PEditorModelResponse { + constructor(editorModel: IEditorModel) { + this.integration = editorModel.integration; + this.scripts = editorModel.scripts; + this.styles = editorModel.styles; + } + + @ApiProperty() + integration: IIntegration; + + // This is a list of URLs that point to the Javascript files the H5P editor needs to load + @ApiProperty() + scripts: string[]; + + // This is a list of URLs that point to the CSS files the H5P editor needs to load + @ApiProperty() + styles: string[]; +} + +export interface GetH5PFileResponse { + data: Readable; + etag?: string; + contentType?: string; + contentLength?: number; + contentRange?: string; + name: string; +} + +interface H5PContentResponse { + h5p: IContentMetadata; + library: string; + params: { + metadata: IContentMetadata; + params: ContentParameters; + }; +} + +export class H5PEditorModelContentResponse extends H5PEditorModelResponse { + constructor(editorModel: IEditorModel, content: H5PContentResponse) { + super(editorModel); + + this.library = content.library; + this.metadata = content.params.metadata; + this.params = content.params.params; + } + + @ApiProperty() + library: string; + + @ApiProperty() + metadata: IContentMetadata; + + @ApiProperty() + params: unknown; +} + +export class H5PContentMetadata { + constructor(metadata: IContentMetadata) { + this.mainLibrary = metadata.mainLibrary; + this.title = metadata.title; + } + + @ApiProperty() + title: string; + + @ApiProperty() + mainLibrary: string; +} + +export class H5PSaveResponse { + constructor(id: string, metadata: IContentMetadata) { + this.contentId = id; + this.metadata = metadata; + } + + @ApiProperty() + contentId!: string; + + @ApiProperty({ type: H5PContentMetadata }) + metadata!: H5PContentMetadata; +} + +export interface GetFileResponse { + data: Readable; + etag?: string; + contentType?: string; + contentLength?: number; + contentRange?: string; + name: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/h5p-file.dto.ts b/apps/server/src/modules/h5p-editor/controller/dto/h5p-file.dto.ts new file mode 100644 index 00000000000..1ac4fc092e3 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/h5p-file.dto.ts @@ -0,0 +1,32 @@ +import { Readable } from 'stream'; +import { File } from '@shared/infra/s3-client'; + +export class H5pFileDto implements File { + constructor(file: H5pFileDto) { + this.name = file.name; + this.data = file.data; + this.mimeType = file.mimeType; + } + + name: string; + + data: Readable; + + mimeType: string; +} + +export interface GetH5pFileResponse { + data: Readable; + etag?: string; + contentType?: string; + contentLength?: number; + contentRange?: string; + name: string; +} + +export interface GetLibraryFile { + data: Readable; + contentType: string; + contentLength: number; + contentRange?: { start: number; end: number }; +} diff --git a/apps/server/src/modules/h5p-editor/controller/dto/index.ts b/apps/server/src/modules/h5p-editor/controller/dto/index.ts new file mode 100644 index 00000000000..dab538acc70 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/index.ts @@ -0,0 +1,6 @@ +export * from './ajax'; +export * from './content-file.url.params'; +export * from './h5p-editor.params'; +export * from './library-file.url.params'; +export * from './h5p-file.dto'; +export * from './h5p-editor.response'; diff --git a/apps/server/src/modules/h5p-editor/controller/dto/library-file.url.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/library-file.url.params.ts new file mode 100644 index 00000000000..40d036b6e1f --- /dev/null +++ b/apps/server/src/modules/h5p-editor/controller/dto/library-file.url.params.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LibraryFileUrlParams { + @ApiProperty() + @IsString() + @IsNotEmpty() + ubername!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + file!: string; +} diff --git a/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts b/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts index 519f96e75e1..8c80d6bc0e4 100644 --- a/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts +++ b/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts @@ -1,49 +1,58 @@ -import { BadRequestException, Controller, ForbiddenException, Get, InternalServerErrorException } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + HttpStatus, + InternalServerErrorException, + Param, + Post, + Query, + Req, + Res, + StreamableFile, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; +import { ICurrentUser, CurrentUser } from '@modules/authentication'; +import { Request, Response } from 'express'; import { Authenticate } from '@modules/authentication/decorator/auth.decorator'; -// Dummy html response so we can test i-frame integration -const dummyResponse = (title: string) => ` - - - - - - - ${title} - - -

${title}

-

This response can be used for testing

- - -`; +import { H5PEditorUc } from '../uc/h5p.uc'; + +import { AjaxPostBodyParamsTransformPipe } from './dto/ajax/post.body.params.transform-pipe'; +import { + AjaxGetQueryParams, + AjaxPostBodyParams, + AjaxPostQueryParams, + ContentFileUrlParams, + GetH5PContentParams, + GetH5PEditorParams, + GetH5PEditorParamsCreate, + LibraryFileUrlParams, + PostH5PContentCreateParams, + SaveH5PEditorParams, +} from './dto'; +import { H5PEditorModelContentResponse, H5PEditorModelResponse, H5PSaveResponse } from './dto/h5p-editor.response'; @ApiTags('h5p-editor') @Authenticate('jwt') @Controller('h5p-editor') export class H5PEditorController { - @ApiOperation({ summary: 'Return dummy HTML for testing' }) - @ApiResponse({ status: 400, type: ApiValidationError }) - @ApiResponse({ status: 400, type: BadRequestException }) - @ApiResponse({ status: 403, type: ForbiddenException }) - @ApiResponse({ status: 500, type: InternalServerErrorException }) - @Get('/:contentId/play') - async getPlayer() { - // Dummy Response - return Promise.resolve(dummyResponse('H5P Player Dummy')); - } + constructor(private h5pEditorUc: H5PEditorUc) {} @ApiOperation({ summary: 'Return dummy HTML for testing' }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 400, type: BadRequestException }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) - @Get('/:contentId/edit') - async getEditor() { - // Dummy Response - return Promise.resolve(dummyResponse('H5P Editor Dummy')); + @Get('/play/:contentId') + async getPlayer(@CurrentUser() currentUser: ICurrentUser, @Param() params: GetH5PContentParams) { + return this.h5pEditorUc.getH5pPlayer(currentUser, params.contentId); } // Other Endpoints (incomplete list), paths not final @@ -53,4 +62,173 @@ export class H5PEditorController { // - ajax endpoint for h5p (e.g. GET/POST `/ajax/*`) // - static files from h5p-core (e.g. GET `/core/*`) // - static files for editor (e.g. GET `/editor/*`) + + @Get('libraries/:ubername/:file(*)') + async getLibraryFile(@Param() params: LibraryFileUrlParams, @Req() req: Request) { + const { data, contentType, contentLength } = await this.h5pEditorUc.getLibraryFile(params.ubername, params.file); + + req.on('close', () => data.destroy()); + + return new StreamableFile(data, { type: contentType, length: contentLength }); + } + + @Get('params/:id') + async getContentParameters(@Param('id') id: string, @CurrentUser() currentUser: ICurrentUser) { + const content = await this.h5pEditorUc.getContentParameters(id, currentUser); + + return content; + } + + @Get('content/:id/:filename(*)') + async getContentFile( + @Param() params: ContentFileUrlParams, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @CurrentUser() currentUser: ICurrentUser + ) { + const { data, contentType, contentLength, contentRange } = await this.h5pEditorUc.getContentFile( + params.id, + params.filename, + req, + currentUser + ); + + H5PEditorController.setRangeResponseHeaders(res, contentLength, contentRange); + + req.on('close', () => data.destroy()); + + return new StreamableFile(data, { type: contentType, length: contentLength }); + } + + @Get('temp-files/:file(*)') + async getTemporaryFile( + @CurrentUser() currentUser: ICurrentUser, + @Param('file') file: string, + @Req() req: Request, + @Res({ passthrough: true }) res: Response + ) { + const { data, contentType, contentLength, contentRange } = await this.h5pEditorUc.getTemporaryFile( + file, + req, + currentUser + ); + + H5PEditorController.setRangeResponseHeaders(res, contentLength, contentRange); + + req.on('close', () => data.destroy()); + + return new StreamableFile(data, { type: contentType, length: contentLength }); + } + + @Get('ajax') + async getAjax(@Query() query: AjaxGetQueryParams, @CurrentUser() currentUser: ICurrentUser) { + const response = this.h5pEditorUc.getAjax(query, currentUser); + + return response; + } + + @Post('ajax') + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'file', maxCount: 1 }, + { name: 'h5p', maxCount: 1 }, + ]) + ) + async postAjax( + @Body(AjaxPostBodyParamsTransformPipe) body: AjaxPostBodyParams, + @Query() query: AjaxPostQueryParams, + @CurrentUser() currentUser: ICurrentUser, + @UploadedFiles() files?: { file?: Express.Multer.File[]; h5p?: Express.Multer.File[] } + ) { + const contentFile = files?.file?.[0]; + const h5pFile = files?.h5p?.[0]; + + const result = await this.h5pEditorUc.postAjax(currentUser, query, body, contentFile, h5pFile); + + return result; + } + + @Post('/delete/:contentId') + async deleteH5pContent( + @Param() params: GetH5PContentParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const deleteSuccessfull = this.h5pEditorUc.deleteH5pContent(currentUser, params.contentId); + + return deleteSuccessfull; + } + + @Get('/edit/:language') + @ApiResponse({ status: 200, type: H5PEditorModelResponse }) + async getNewH5PEditor(@Param() params: GetH5PEditorParamsCreate, @CurrentUser() currentUser: ICurrentUser) { + const editorModel = await this.h5pEditorUc.getEmptyH5pEditor(currentUser, params.language); + + return new H5PEditorModelResponse(editorModel); + } + + @Get('/edit/:contentId/:language') + @ApiResponse({ status: 200, type: H5PEditorModelContentResponse }) + async getH5PEditor(@Param() params: GetH5PEditorParams, @CurrentUser() currentUser: ICurrentUser) { + const { editorModel, content } = await this.h5pEditorUc.getH5pEditor( + currentUser, + params.contentId, + params.language + ); + + return new H5PEditorModelContentResponse(editorModel, content); + } + + @Post('/edit') + @ApiResponse({ status: 201, type: H5PSaveResponse }) + async createH5pContent(@Body() body: PostH5PContentCreateParams, @CurrentUser() currentUser: ICurrentUser) { + const response = await this.h5pEditorUc.createH5pContentGetMetadata( + currentUser, + body.params.params, + body.params.metadata, + body.library, + body.parentType, + body.parentId + ); + + const saveResponse = new H5PSaveResponse(response.id, response.metadata); + + return saveResponse; + } + + @Post('/edit/:contentId') + @ApiResponse({ status: 201, type: H5PSaveResponse }) + async saveH5pContent( + @Body() body: PostH5PContentCreateParams, + @Param() params: SaveH5PEditorParams, + @CurrentUser() currentUser: ICurrentUser + ) { + const response = await this.h5pEditorUc.saveH5pContentGetMetadata( + params.contentId, + currentUser, + body.params.params, + body.params.metadata, + body.library, + body.parentType, + body.parentId + ); + + const saveResponse = new H5PSaveResponse(response.id, response.metadata); + + return saveResponse; + } + + private static setRangeResponseHeaders(res: Response, contentLength: number, range?: { start: number; end: number }) { + if (range) { + const contentRangeHeader = `bytes ${range.start}-${range.end}/${contentLength}`; + + res.set({ + 'Accept-Ranges': 'bytes', + 'Content-Range': contentRangeHeader, + }); + + res.status(HttpStatus.PARTIAL_CONTENT); + } else { + res.status(HttpStatus.OK); + } + } } diff --git a/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.spec.ts b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.spec.ts new file mode 100644 index 00000000000..cea707c8ccf --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.spec.ts @@ -0,0 +1,42 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { ContentMetadata, H5PContent, H5PContentParentType, IH5PContentProperties } from './h5p-content.entity'; + +describe('H5PContent class', () => { + describe('when an H5PContent instance is created', () => { + const setup = () => { + const dummyIH5PContentProperties: IH5PContentProperties = { + creatorId: '507f1f77bcf86cd799439011', + parentType: H5PContentParentType.Lesson, + parentId: '507f1f77bcf86cd799439012', + schoolId: '507f1f77bcf86cd799439013', + metadata: new ContentMetadata({ + embedTypes: ['iframe'], + language: 'en', + mainLibrary: 'mainLibrary123', + defaultLanguage: 'en', + license: 'MIT', + title: 'Title Example', + preloadedDependencies: [], + dynamicDependencies: [], + editorDependencies: [], + }), + content: {}, + }; + + const h5pContent = new H5PContent(dummyIH5PContentProperties); + return { h5pContent, dummyIH5PContentProperties }; + }; + + it('should correctly return the creatorId', () => { + const { h5pContent, dummyIH5PContentProperties } = setup(); + const expectedCreatorId = new ObjectId(dummyIH5PContentProperties.creatorId).toHexString(); + expect(h5pContent.creatorId).toBe(expectedCreatorId); + }); + + it('should correctly return the schoolId', () => { + const { h5pContent, dummyIH5PContentProperties } = setup(); + const expectedSchoolId = new ObjectId(dummyIH5PContentProperties.schoolId).toHexString(); + expect(h5pContent.schoolId).toBe(expectedSchoolId); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts new file mode 100644 index 00000000000..3f9e6113172 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts @@ -0,0 +1,163 @@ +import { IContentMetadata, ILibraryName } from '@lumieducation/h5p-server'; +import { IContentAuthor, IContentChange } from '@lumieducation/h5p-server/build/src/types'; +import { Embeddable, Embedded, Entity, Enum, Index, JsonType, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseEntityWithTimestamps, EntityId } from '@shared/domain'; + +@Embeddable() +export class ContentMetadata implements IContentMetadata { + @Property({ nullable: true }) + dynamicDependencies?: ILibraryName[]; + + @Property({ nullable: true }) + editorDependencies?: ILibraryName[]; + + @Property() + embedTypes: ('iframe' | 'div')[]; + + @Property({ nullable: true }) + h?: string; + + @Property() + language: string; + + @Property() + mainLibrary: string; + + @Property({ nullable: true }) + metaDescription?: string; + + @Property({ nullable: true }) + metaKeywords?: string; + + @Property() + preloadedDependencies: ILibraryName[]; + + @Property({ nullable: true }) + w?: string; + + @Property() + defaultLanguage: string; + + @Property({ nullable: true }) + a11yTitle?: string; + + @Property() + license: string; + + @Property({ nullable: true }) + licenseVersion?: string; + + @Property({ nullable: true }) + yearFrom?: string; + + @Property({ nullable: true }) + yearTo?: string; + + @Property({ nullable: true }) + source?: string; + + @Property() + title: string; + + @Property({ nullable: true }) + authors?: IContentAuthor[]; + + @Property({ nullable: true }) + licenseExtras?: string; + + @Property({ nullable: true }) + changes?: IContentChange[]; + + @Property({ nullable: true }) + authorComments?: string; + + @Property({ nullable: true }) + contentType?: string; + + constructor(metadata: IContentMetadata) { + this.embedTypes = metadata.embedTypes; + this.language = metadata.language; + this.mainLibrary = metadata.mainLibrary; + this.defaultLanguage = metadata.defaultLanguage; + this.license = metadata.license; + this.title = metadata.title; + this.preloadedDependencies = metadata.preloadedDependencies; + this.dynamicDependencies = metadata.dynamicDependencies; + this.editorDependencies = metadata.editorDependencies; + this.h = metadata.h; + this.metaDescription = metadata.metaDescription; + this.metaKeywords = metadata.metaKeywords; + this.w = metadata.w; + this.a11yTitle = metadata.a11yTitle; + this.licenseVersion = metadata.licenseVersion; + this.yearFrom = metadata.yearFrom; + this.yearTo = metadata.yearTo; + this.source = metadata.source; + this.authors = metadata.authors; + this.licenseExtras = metadata.licenseExtras; + this.changes = metadata.changes; + this.authorComments = metadata.authorComments; + this.contentType = metadata.contentType; + } +} + +export enum H5PContentParentType { + 'Lesson' = 'lessons', +} + +export interface IH5PContentProperties { + creatorId: EntityId; + parentType: H5PContentParentType; + parentId: EntityId; + schoolId: EntityId; + metadata: ContentMetadata; + content: unknown; +} + +@Entity({ tableName: 'h5p-editor-content' }) +export class H5PContent extends BaseEntityWithTimestamps { + @Property({ fieldName: 'creator' }) + _creatorId: ObjectId; + + get creatorId(): EntityId { + return this._creatorId.toHexString(); + } + + @Index() + @Enum() + parentType: H5PContentParentType; + + @Index() + @Property({ fieldName: 'parent' }) + _parentId: ObjectId; + + get parentId(): EntityId { + return this._parentId.toHexString(); + } + + @Property({ fieldName: 'school' }) + _schoolId: ObjectId; + + get schoolId(): EntityId { + return this._schoolId.toHexString(); + } + + @Embedded(() => ContentMetadata) + metadata: ContentMetadata; + + @Property({ type: JsonType }) + content: unknown; + + constructor({ parentType, parentId, creatorId, schoolId, metadata, content }: IH5PContentProperties) { + super(); + + this.parentType = parentType; + this._parentId = new ObjectId(parentId); + this._creatorId = new ObjectId(creatorId); + this._schoolId = new ObjectId(schoolId); + + this.metadata = metadata; + this.content = content; + } +} diff --git a/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts b/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts new file mode 100644 index 00000000000..a4ebeb30e8a --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts @@ -0,0 +1,41 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { ITemporaryFile, IFileStats } from '@lumieducation/h5p-server'; +import { BaseEntityWithTimestamps } from '@shared/domain'; + +export interface ITemporaryFileProperties { + filename: string; + ownedByUserId: string; + expiresAt: Date; + birthtime: Date; + size: number; +} + +@Entity({ tableName: 'h5p-editor-temp-file' }) +export class H5pEditorTempFile extends BaseEntityWithTimestamps implements ITemporaryFile, IFileStats { + /** + * The name by which the file can be identified; can be a path including subdirectories (e.g. 'images/xyz.png') + */ + @Property() + filename: string; + + @Property() + expiresAt: Date; + + @Property() + ownedByUserId: string; + + @Property() + birthtime: Date; + + @Property() + size: number; + + constructor({ filename, ownedByUserId, expiresAt, birthtime, size }: ITemporaryFileProperties) { + super(); + this.filename = filename; + this.ownedByUserId = ownedByUserId; + this.expiresAt = expiresAt; + this.birthtime = birthtime; + this.size = size; + } +} diff --git a/apps/server/src/modules/h5p-editor/entity/index.ts b/apps/server/src/modules/h5p-editor/entity/index.ts new file mode 100644 index 00000000000..e95c0f12c94 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/index.ts @@ -0,0 +1,3 @@ +export * from './h5p-content.entity'; +export * from './library.entity'; +export * from './h5p-editor-tempfile.entity'; diff --git a/apps/server/src/modules/h5p-editor/entity/library.entity.spec.ts b/apps/server/src/modules/h5p-editor/entity/library.entity.spec.ts new file mode 100644 index 00000000000..7a917398411 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/library.entity.spec.ts @@ -0,0 +1,223 @@ +import { ILibraryMetadata } from '@lumieducation/h5p-server'; +import { FileMetadata, InstalledLibrary, LibraryName, Path } from './library.entity'; + +describe('InstalledLibrary', () => { + let addonLibVersionOne: InstalledLibrary; + let addonLibVersionOneMinorChange: InstalledLibrary; + let addonLibVersionOnePatchChange: InstalledLibrary; + let addonLibVersionTwo: InstalledLibrary; + + beforeAll(() => { + const testingLibMetadataVersionOne: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'testing', + majorVersion: 1, + minorVersion: 2, + }; + const testingLibVersionOne = new InstalledLibrary(testingLibMetadataVersionOne); + testingLibVersionOne.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionOne: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'addonVersionOne', + majorVersion: 1, + minorVersion: 2, + }; + addonLibVersionOne = new InstalledLibrary(addonLibMetadataVersionOne); + addonLibVersionOne.addTo = { player: { machineNames: [testingLibVersionOne.machineName] } }; + + const testingLibMetadataVersionOneMinorChange: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'testing', + majorVersion: 1, + minorVersion: 5, + }; + const testingLibVersionOneMinorChange = new InstalledLibrary(testingLibMetadataVersionOneMinorChange); + testingLibVersionOne.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionOneMinorChange: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'addonVersionOne', + majorVersion: 1, + minorVersion: 5, + }; + addonLibVersionOneMinorChange = new InstalledLibrary(addonLibMetadataVersionOneMinorChange); + addonLibVersionOneMinorChange.addTo = { player: { machineNames: [testingLibVersionOneMinorChange.machineName] } }; + + const testingLibMetadataVersionOnePatchChange: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 5, + machineName: 'testing', + majorVersion: 1, + minorVersion: 2, + }; + const testingLibVersionOnePatchChange = new InstalledLibrary(testingLibMetadataVersionOnePatchChange); + testingLibVersionOne.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionOnePatchChange: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 5, + machineName: 'addonVersionOne', + majorVersion: 1, + minorVersion: 2, + }; + addonLibVersionOnePatchChange = new InstalledLibrary(addonLibMetadataVersionOnePatchChange); + addonLibVersionOnePatchChange.addTo = { player: { machineNames: [testingLibVersionOnePatchChange.machineName] } }; + + const testingLibMetadataVersionTwo: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 4, + machineName: 'addonVersionTwo', + majorVersion: 2, + minorVersion: 3, + }; + const testingLibVersionTwo = new InstalledLibrary(testingLibMetadataVersionTwo); + testingLibVersionTwo.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionTwo: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 4, + machineName: 'addonVersionTwo', + majorVersion: 2, + minorVersion: 3, + }; + addonLibVersionTwo = new InstalledLibrary(addonLibMetadataVersionTwo); + addonLibVersionTwo.addTo = { player: { machineNames: [testingLibVersionTwo.machineName] } }; + }); + + describe('simple_compare', () => { + it('should return 1 if a is greater than b', () => { + expect(InstalledLibrary.simple_compare(5, 3)).toBe(1); + }); + + it('should return -1 if a is less than b', () => { + expect(InstalledLibrary.simple_compare(3, 5)).toBe(-1); + }); + + it('should return 0 if a is equal to b', () => { + expect(InstalledLibrary.simple_compare(3, 3)).toBe(0); + }); + }); + + describe('compare', () => { + describe('when compare', () => {}); + it('should return -1', () => { + const result = addonLibVersionOne.compare(addonLibVersionTwo); + expect(result).toBe(-1); + }); + describe('when compare library Version', () => { + it('should call compareVersions', () => { + const compareVersionsSpy = ( + jest.spyOn(addonLibVersionOne, 'compareVersions') as jest.SpyInstance + ).mockReturnValueOnce(0); + addonLibVersionOne.compare(addonLibVersionOne); + expect(compareVersionsSpy).toHaveBeenCalled(); + compareVersionsSpy.mockRestore(); + }); + }); + }); + + describe('compareVersions', () => { + describe('when calling compareVersions with Major Change', () => { + it('should return -1 and call simple_compare once', () => { + const simpleCompareSpy = jest.spyOn(InstalledLibrary, 'simple_compare'); + const result = addonLibVersionOne.compareVersions(addonLibVersionTwo); + expect(result).toBe(-1); + expect(simpleCompareSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('when calling compareVersions with Minor Change', () => { + it('should return -1 and call simple_compare three times', () => { + const simpleCompareSpy = jest.spyOn(InstalledLibrary, 'simple_compare'); + const result = addonLibVersionOne.compareVersions(addonLibVersionOneMinorChange); + expect(result).toBe(-1); + expect(simpleCompareSpy).toHaveBeenCalledTimes(3); + }); + }); + + describe('when calling compareVersions with same Major & Minor Versions', () => { + it('should return call simple_compare with patch versions', () => { + const simpleCompareSpy = jest.spyOn(InstalledLibrary, 'simple_compare'); + const result = addonLibVersionOne.compareVersions(addonLibVersionOnePatchChange); + expect(result).toBe(-1); + expect(simpleCompareSpy).toHaveBeenCalledWith( + addonLibVersionOne.patchVersion, + addonLibVersionOnePatchChange.patchVersion + ); + }); + }); + }); +}); + +describe('LibraryName', () => { + let libraryName: LibraryName; + + beforeEach(() => { + libraryName = new LibraryName('test', 1, 2); + }); + + it('should be defined', () => { + expect(libraryName).toBeDefined(); + }); + + it('should create libraryName', () => { + const newlibraryName = new LibraryName('newtest', 1, 2); + expect(newlibraryName.machineName).toEqual('newtest'); + }); + + it('should change libraryName', () => { + libraryName.machineName = 'changed-name'; + expect(libraryName.machineName).toEqual('changed-name'); + }); +}); + +describe('Path', () => { + let path: Path; + + beforeEach(() => { + path = new Path(''); + }); + + it('should be defined', () => { + expect(path).toBeDefined(); + }); + + it('should create path', () => { + const newPath = new Path('test-path'); + expect(newPath.path).toEqual('test-path'); + }); + + it('should change path', () => { + path.path = 'new-path'; + expect(path.path).toEqual('new-path'); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/entity/library.entity.ts b/apps/server/src/modules/h5p-editor/entity/library.entity.ts new file mode 100644 index 00000000000..868397f7266 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/entity/library.entity.ts @@ -0,0 +1,249 @@ +import { IInstalledLibrary, ILibraryName } from '@lumieducation/h5p-server'; +import { IFileStats, ILibraryMetadata, IPath } from '@lumieducation/h5p-server/build/src/types'; +import { Entity, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain'; + +export class Path implements IPath { + @Property() + path: string; + + constructor(path: string) { + this.path = path; + } +} + +export class LibraryName implements ILibraryName { + @Property() + machineName: string; + + @Property() + majorVersion: number; + + @Property() + minorVersion: number; + + constructor(machineName: string, majorVersion: number, minorVersion: number) { + this.machineName = machineName; + this.majorVersion = majorVersion; + this.minorVersion = minorVersion; + } +} + +export class FileMetadata implements IFileStats { + name: string; + + birthtime: Date; + + size: number; + + constructor(name: string, birthtime: Date, size: number) { + this.name = name; + this.birthtime = birthtime; + this.size = size; + } +} + +@Entity({ tableName: 'h5p_library' }) +export class InstalledLibrary extends BaseEntityWithTimestamps implements IInstalledLibrary { + @Property() + machineName: string; + + @Property() + majorVersion: number; + + @Property() + minorVersion: number; + + @Property() + patchVersion: number; + + /** + * Addons can be added to other content types by + */ + @Property({ nullable: true }) + addTo?: { + content?: { + types?: { + text?: { + /** + * If any string property in the parameters matches the regex, + * the addon will be activated for the content. + */ + regex?: string; + }; + }[]; + }; + /** + * Contains cases in which the library should be added to the editor. + * + * This is an extension to the H5P library metadata structure made by + * h5p-nodejs-library. That way addons can specify to which editors + * they should be added in general. The PHP implementation hard-codes + * this list into the server, which we want to avoid here. + */ + editor?: { + /** + * A list of machine names in which the addon should be added. + */ + machineNames: string[]; + }; + /** + * Contains cases in which the library should be added to the player. + * + * This is an extension to the H5P library metadata structure made by + * h5p-nodejs-library. That way addons can specify to which editors + * they should be added in general. The PHP implementation hard-codes + * this list into the server, which we want to avoid here. + */ + player?: { + /** + * A list of machine names in which the addon should be added. + */ + machineNames: string[]; + }; + }; + + /** + * If set to true, the library can only be used be users who have this special + * privilege. + */ + @Property() + restricted: boolean; + + @Property({ nullable: true }) + author?: string; + + /** + * The core API required to run the library. + */ + @Property({ nullable: true }) + coreApi?: { + majorVersion: number; + minorVersion: number; + }; + + @Property({ nullable: true }) + description?: string; + + @Property({ nullable: true }) + dropLibraryCss?: { + machineName: string; + }[]; + + @Property({ nullable: true }) + dynamicDependencies?: LibraryName[]; + + @Property({ nullable: true }) + editorDependencies?: LibraryName[]; + + @Property({ nullable: true }) + embedTypes?: ('iframe' | 'div')[]; + + @Property({ nullable: true }) + fullscreen?: 0 | 1; + + @Property({ nullable: true }) + h?: number; + + @Property({ nullable: true }) + license?: string; + + @Property({ nullable: true }) + metadataSettings?: { + disable: 0 | 1; + disableExtraTitleField: 0 | 1; + }; + + @Property({ nullable: true }) + preloadedCss?: Path[]; + + @Property({ nullable: true }) + preloadedDependencies?: LibraryName[]; + + @Property({ nullable: true }) + preloadedJs?: Path[]; + + @Property() + runnable: boolean | 0 | 1; + + @Property() + title: string; + + @Property({ nullable: true }) + w?: number; + + @Property({ nullable: true }) + requiredExtensions?: { + sharedState: number; + }; + + @Property({ nullable: true }) + state?: { + snapshotSchema: boolean; + opSchema: boolean; + snapshotLogicChecks: boolean; + opLogicChecks: boolean; + }; + + @Property() + files: FileMetadata[]; + + public static simple_compare(a: number, b: number): number { + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } + + public compare(otherLibrary: IInstalledLibrary): number { + if (this.machineName === otherLibrary.machineName) { + return this.compareVersions(otherLibrary); + } + return this.machineName > otherLibrary.machineName ? 1 : -1; + } + + public compareVersions(otherLibrary: ILibraryName & { patchVersion?: number }): number { + let result = InstalledLibrary.simple_compare(this.majorVersion, otherLibrary.majorVersion); + if (result !== 0) { + return result; + } + result = InstalledLibrary.simple_compare(this.minorVersion, otherLibrary.minorVersion); + if (result !== 0) { + return result; + } + return InstalledLibrary.simple_compare(this.patchVersion, otherLibrary.patchVersion as number); + } + + constructor(libraryMetadata: ILibraryMetadata, restricted = false, files: FileMetadata[] = []) { + super(); + this.machineName = libraryMetadata.machineName; + this.majorVersion = libraryMetadata.majorVersion; + this.minorVersion = libraryMetadata.minorVersion; + this.patchVersion = libraryMetadata.patchVersion; + this.runnable = libraryMetadata.runnable; + this.title = libraryMetadata.title; + this.addTo = libraryMetadata.addTo; + this.author = libraryMetadata.author; + this.coreApi = libraryMetadata.coreApi; + this.description = libraryMetadata.description; + this.dropLibraryCss = libraryMetadata.dropLibraryCss; + this.dynamicDependencies = libraryMetadata.dynamicDependencies; + this.editorDependencies = libraryMetadata.editorDependencies; + this.embedTypes = libraryMetadata.embedTypes; + this.fullscreen = libraryMetadata.fullscreen; + this.h = libraryMetadata.h; + this.license = libraryMetadata.license; + this.metadataSettings = libraryMetadata.metadataSettings; + this.preloadedCss = libraryMetadata.preloadedCss; + this.preloadedDependencies = libraryMetadata.preloadedDependencies; + this.preloadedJs = libraryMetadata.preloadedJs; + this.w = libraryMetadata.w; + this.requiredExtensions = libraryMetadata.requiredExtensions; + this.state = libraryMetadata.state; + this.restricted = restricted; + this.files = files; + } +} diff --git a/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts b/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts index fccb5e2841b..62cd06dcf6e 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts @@ -1,27 +1,49 @@ import { DynamicModule, Module } from '@nestjs/common'; -import { Account, Role, SchoolEntity, SchoolYearEntity, User } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { MongoDatabaseModuleOptions } from '@shared/infra/database/mongo-memory-database/types'; +import { ALL_ENTITIES } from '@shared/domain'; +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@shared/infra/database'; import { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq'; +import { S3ClientModule } from '@shared/infra/s3-client'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { AuthorizationModule } from '@modules/authorization'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { UserModule } from '@modules/user'; import { AuthenticationApiModule } from '../authentication/authentication-api.module'; import { H5PEditorModule } from './h5p-editor.module'; +import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; +import { ContentStorage, LibraryStorage, TemporaryFileStorage } from './service'; +import { H5PEditorUc } from './uc/h5p.uc'; +import { s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; +import { H5PEditorController } from './controller'; +import { H5PEditorProvider, H5PAjaxEndpointProvider, H5PPlayerProvider } from './provider'; +import { H5PContent } from './entity'; const imports = [ H5PEditorModule, - MongoMemoryDatabaseModule.forRoot({ entities: [Account, Role, SchoolEntity, SchoolYearEntity, User] }), + MongoMemoryDatabaseModule.forRoot({ entities: [...ALL_ENTITIES, H5PContent] }), AuthenticationApiModule, - AuthorizationModule, + AuthorizationReferenceModule, AuthenticationModule, + UserModule, CoreModule, LoggerModule, RabbitMQWrapperTestModule, + S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), ]; -const controllers = []; -const providers = []; +const controllers = [H5PEditorController]; +const providers = [ + H5PEditorUc, + H5PPlayerProvider, + H5PEditorProvider, + H5PAjaxEndpointProvider, + H5PContentRepo, + LibraryRepo, + TemporaryFileRepo, + ContentStorage, + LibraryStorage, + TemporaryFileStorage, +]; + @Module({ imports, controllers, diff --git a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts index a5b667897b3..f02084aa4e5 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts @@ -1,8 +1,34 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { S3Config } from '@shared/infra/s3-client'; const h5pEditorConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, }; +export const translatorConfig = { + AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(','), +}; + +export const H5P_CONTENT_S3_CONNECTION = 'H5P_CONTENT_S3_CONNECTION'; +export const H5P_LIBRARIES_S3_CONNECTION = 'H5P_LIBRARIES_S3_CONNECTION'; + +export const s3ConfigContent: S3Config = { + connectionName: H5P_CONTENT_S3_CONNECTION, + endpoint: Configuration.get('H5P_EDITOR__S3_ENDPOINT') as string, + region: Configuration.get('H5P_EDITOR__S3_REGION') as string, + bucket: Configuration.get('H5P_EDITOR__S3_BUCKET_CONTENT') as string, + accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID_RW') as string, + secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY_RW') as string, +}; + +export const s3ConfigLibraries: S3Config = { + connectionName: H5P_LIBRARIES_S3_CONNECTION, + endpoint: Configuration.get('H5P_EDITOR__S3_ENDPOINT') as string, + region: Configuration.get('H5P_EDITOR__S3_REGION') as string, + bucket: Configuration.get('H5P_EDITOR__S3_BUCKET_LIBRARIES') as string, + accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID_R') as string, + secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY_R') as string, +}; + export const config = () => h5pEditorConfig; diff --git a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts index 442f0a04409..34efcedd69e 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts @@ -2,14 +2,22 @@ import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { Account, Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain'; +import { ALL_ENTITIES } from '@shared/domain'; +import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; -import { AuthorizationModule } from '@modules/authorization'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { UserModule } from '@modules/user'; +import { S3ClientModule } from '@shared/infra/s3-client'; import { AuthenticationModule } from '../authentication/authentication.module'; import { H5PEditorController } from './controller/h5p-editor.controller'; -import { config } from './h5p-editor.config'; +import { H5PContent, InstalledLibrary, H5pEditorTempFile } from './entity'; +import { config, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; +import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; +import { ContentStorage, LibraryStorage, TemporaryFileStorage } from './service'; +import { H5PEditorProvider, H5PAjaxEndpointProvider, H5PPlayerProvider } from './provider'; +import { H5PEditorUc } from './uc/h5p.uc'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -19,8 +27,10 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { const imports = [ AuthenticationModule, - AuthorizationModule, + AuthorizationReferenceModule, CoreModule, + UserModule, + RabbitMQWrapperModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -28,16 +38,28 @@ const imports = [ clientUrl: DB_URL, password: DB_PASSWORD, user: DB_USERNAME, - entities: [User, Account, Role, SchoolEntity, SystemEntity, SchoolYearEntity], - - // debug: true, // use it for locally debugging of querys + // Needs ALL_ENTITIES for authorization + entities: [...ALL_ENTITIES, H5PContent, H5pEditorTempFile, InstalledLibrary], }), ConfigModule.forRoot(createConfigModuleOptions(config)), + S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), ]; const controllers = [H5PEditorController]; -const providers = [Logger]; +const providers = [ + Logger, + H5PEditorUc, + H5PContentRepo, + LibraryRepo, + TemporaryFileRepo, + H5PEditorProvider, + H5PPlayerProvider, + H5PAjaxEndpointProvider, + ContentStorage, + LibraryStorage, + TemporaryFileStorage, +]; @Module({ imports, diff --git a/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.spec.ts b/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.spec.ts new file mode 100644 index 00000000000..164c19dc269 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.spec.ts @@ -0,0 +1,20 @@ +import { AuthorizableReferenceType } from '@modules/authorization/domain'; +import { NotImplementedException } from '@nestjs/common'; +import { H5PContentParentType } from '../entity'; +import { H5PContentMapper } from './h5p-content.mapper'; + +describe('H5PContentMapper', () => { + describe('mapToAllowedAuthorizationEntityType()', () => { + it('should return allowed type equal Course', () => { + const result = H5PContentMapper.mapToAllowedAuthorizationEntityType(H5PContentParentType.Lesson); + expect(result).toBe(AuthorizableReferenceType.Lesson); + }); + + it('should throw NotImplementedException', () => { + const exec = () => { + H5PContentMapper.mapToAllowedAuthorizationEntityType('' as H5PContentParentType); + }; + expect(exec).toThrowError(NotImplementedException); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.ts b/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.ts new file mode 100644 index 00000000000..1b760036db6 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.ts @@ -0,0 +1,19 @@ +import { NotImplementedException } from '@nestjs/common'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; +import { H5PContentParentType } from '../entity'; + +export class H5PContentMapper { + static mapToAllowedAuthorizationEntityType(type: H5PContentParentType): AuthorizableReferenceType { + const types = new Map(); + + types.set(H5PContentParentType.Lesson, AuthorizableReferenceType.Lesson); + + const res = types.get(type); + + if (!res) { + throw new NotImplementedException(); + } + + return res; + } +} diff --git a/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.spec.ts b/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.spec.ts new file mode 100644 index 00000000000..ad5d5332cc0 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.spec.ts @@ -0,0 +1,26 @@ +import { H5pError } from '@lumieducation/h5p-server'; +import { HttpException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { H5PErrorMapper } from './h5p-error.mapper'; + +describe('H5PErrorMapper', () => { + let h5pErrorMapper: H5PErrorMapper; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + providers: [H5PErrorMapper], + }).compile(); + + h5pErrorMapper = app.get(H5PErrorMapper); + }); + + describe('mapH5pError', () => { + it('should map H5pError to HttpException', () => { + const error = new H5pError('h5p error massage'); + const result = h5pErrorMapper.mapH5pError(error); + + expect(result).toBeInstanceOf(HttpException); + expect(result.message).toEqual('h5p error massage'); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.ts b/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.ts new file mode 100644 index 00000000000..1cd69875985 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/mapper/h5p-error.mapper.ts @@ -0,0 +1,8 @@ +import { H5pError } from '@lumieducation/h5p-server'; +import { HttpException } from '@nestjs/common'; + +export class H5PErrorMapper { + public mapH5pError(error: H5pError) { + return new HttpException(error.message, error.httpStatusCode); + } +} diff --git a/apps/server/src/modules/h5p-editor/provider/h5p-ajax-endpoint.provider.ts b/apps/server/src/modules/h5p-editor/provider/h5p-ajax-endpoint.provider.ts new file mode 100644 index 00000000000..b5bc43d291c --- /dev/null +++ b/apps/server/src/modules/h5p-editor/provider/h5p-ajax-endpoint.provider.ts @@ -0,0 +1,11 @@ +import { H5PAjaxEndpoint, H5PEditor } from '@lumieducation/h5p-server'; + +export const H5PAjaxEndpointProvider = { + provide: H5PAjaxEndpoint, + inject: [H5PEditor], + useFactory: (h5pEditor: H5PEditor) => { + const h5pAjaxEndpoint = new H5PAjaxEndpoint(h5pEditor); + + return h5pAjaxEndpoint; + }, +}; diff --git a/apps/server/src/modules/h5p-editor/provider/h5p-editor.provider.ts b/apps/server/src/modules/h5p-editor/provider/h5p-editor.provider.ts new file mode 100644 index 00000000000..d7b3e4e5668 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/provider/h5p-editor.provider.ts @@ -0,0 +1,37 @@ +import { H5PEditor, cacheImplementations } from '@lumieducation/h5p-server'; + +import { IH5PEditorOptions, ITranslationFunction } from '@lumieducation/h5p-server/build/src/types'; +import { h5pConfig, h5pUrlGenerator } from '../service/config/h5p-service-config'; +import { ContentStorage, Translator, LibraryStorage, TemporaryFileStorage } from '../service'; + +export const H5PEditorProvider = { + provide: H5PEditor, + inject: [ContentStorage, LibraryStorage, TemporaryFileStorage], + async useFactory( + contentStorage: ContentStorage, + libraryStorage: LibraryStorage, + temporaryStorage: TemporaryFileStorage + ) { + const cache = new cacheImplementations.CachedKeyValueStorage('kvcache'); + + const h5pOptions: IH5PEditorOptions = { + enableHubLocalization: true, + enableLibraryNameLocalization: true, + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const translationFunction: ITranslationFunction = await Translator.translate(); + const h5pEditor = new H5PEditor( + cache, + h5pConfig, + libraryStorage, + contentStorage, + temporaryStorage, + translationFunction, + h5pUrlGenerator, + h5pOptions + ); + h5pEditor.setRenderer((model) => model); + + return h5pEditor; + }, +}; diff --git a/apps/server/src/modules/h5p-editor/provider/h5p-player.provider.ts b/apps/server/src/modules/h5p-editor/provider/h5p-player.provider.ts new file mode 100644 index 00000000000..1f3c83db6f3 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/provider/h5p-player.provider.ts @@ -0,0 +1,27 @@ +import { H5PPlayer, ITranslationFunction } from '@lumieducation/h5p-server'; + +import { h5pConfig, h5pUrlGenerator } from '../service/config/h5p-service-config'; +import { ContentStorage } from '../service/contentStorage.service'; +import { Translator } from '../service/h5p-translator.service'; +import { LibraryStorage } from '../service/libraryStorage.service'; + +export const H5PPlayerProvider = { + provide: H5PPlayer, + inject: [ContentStorage, LibraryStorage], + useFactory: async (contentStorage: ContentStorage, libraryStorage: LibraryStorage) => { + const translationFunction: ITranslationFunction = await Translator.translate(); + const h5pPlayer = new H5PPlayer( + libraryStorage, + contentStorage, + h5pConfig, + undefined, + h5pUrlGenerator, + translationFunction, + undefined + ); + + h5pPlayer.setRenderer((model) => model); + + return h5pPlayer; + }, +}; diff --git a/apps/server/src/modules/h5p-editor/provider/index.ts b/apps/server/src/modules/h5p-editor/provider/index.ts new file mode 100644 index 00000000000..db078ea6d15 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/provider/index.ts @@ -0,0 +1,3 @@ +export * from './h5p-editor.provider'; +export * from './h5p-player.provider'; +export * from './h5p-ajax-endpoint.provider'; diff --git a/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.integration.spec.ts b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.integration.spec.ts new file mode 100644 index 00000000000..e06836ced99 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.integration.spec.ts @@ -0,0 +1,104 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections, h5pContentFactory } from '@shared/testing'; +import { H5PContent } from '../entity'; +import { H5PContentRepo } from './h5p-content.repo'; + +const contentSortFunction = ({ id: aId }: H5PContent, { id: bId }: H5PContent) => aId.localeCompare(bId); + +describe('ContentRepo', () => { + let module: TestingModule; + let repo: H5PContentRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [H5PContent] })], + providers: [H5PContentRepo], + }).compile(); + + repo = module.get(H5PContentRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(H5PContent); + }); + + describe('createContentMetadata', () => { + it('should be able to retrieve entity', async () => { + const h5pContent = h5pContentFactory.build(); + await em.persistAndFlush(h5pContent); + + const result = await repo.findById(h5pContent.id); + + expect(result).toBeDefined(); + expect(result).toEqual(h5pContent); + }); + }); + + describe('findById', () => { + it('should be able to retrieve entity', async () => { + const h5pContent = h5pContentFactory.build(); + await em.persistAndFlush(h5pContent); + + const result = await repo.findById(h5pContent.id); + + expect(result).toBeDefined(); + expect(result).toEqual(h5pContent); + }); + + it('should fail if entity does not exist', async () => { + const id = 'wrong-id'; + + const findById = repo.findById(id); + + await expect(findById).rejects.toThrow(); + }); + }); + + describe('existsOne', () => { + it('should return true if entity exists', async () => { + const h5pContent = h5pContentFactory.build(); + await em.persistAndFlush(h5pContent); + + const result = await repo.existsOne(h5pContent.id); + + expect(result).toBeDefined(); + expect(result).toBeTruthy(); + }); + }); + + describe('deleteContent', () => { + it('should delete data', async () => { + const h5pContent = h5pContentFactory.build(); + await em.persistAndFlush(h5pContent); + + await repo.deleteContent(h5pContent); + + const findById = repo.findById(h5pContent.id); + await expect(findById).rejects.toThrow(); + }); + }); + + describe('getAllContents', () => { + it('should return all metadata', async () => { + const h5pContent = h5pContentFactory.buildList(10); + await em.persistAndFlush(h5pContent); + + const results = await repo.getAllContents(); + + expect(results).toHaveLength(10); + expect(results.sort(contentSortFunction)).toStrictEqual(h5pContent.sort(contentSortFunction)); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts new file mode 100644 index 00000000000..6713aad5d3a --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { BaseRepo } from '@shared/repo/base.repo'; +import { H5PContent } from '../entity'; + +@Injectable() +export class H5PContentRepo extends BaseRepo { + get entityName() { + return H5PContent; + } + + async existsOne(contentId: EntityId): Promise { + const entityCount = await this._em.count(this.entityName, { id: contentId }); + + return entityCount === 1; + } + + async deleteContent(content: H5PContent): Promise { + return this.delete(content); + } + + async findById(contentId: EntityId): Promise { + return this._em.findOneOrFail(this.entityName, { id: contentId }); + } + + async getAllContents(): Promise { + return this._em.find(this.entityName, {}); + } +} diff --git a/apps/server/src/modules/h5p-editor/repo/index.ts b/apps/server/src/modules/h5p-editor/repo/index.ts new file mode 100644 index 00000000000..7d38e6ba404 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/index.ts @@ -0,0 +1,3 @@ +export * from './h5p-content.repo'; +export * from './library.repo'; +export * from './temporary-file.repo'; diff --git a/apps/server/src/modules/h5p-editor/repo/library.repo.spec.ts b/apps/server/src/modules/h5p-editor/repo/library.repo.spec.ts new file mode 100644 index 00000000000..78d219aa557 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/library.repo.spec.ts @@ -0,0 +1,178 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; + +import { ILibraryMetadata } from '@lumieducation/h5p-server'; +import { LibraryRepo } from './library.repo'; +import { FileMetadata, InstalledLibrary } from '../entity'; + +describe('LibraryRepo', () => { + let module: TestingModule; + let libraryRepo: LibraryRepo; + let addonLibVersionOne: InstalledLibrary; + let addonLibVersionOneDuplicate: InstalledLibrary; + let addonLibVersionTwo: InstalledLibrary; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [InstalledLibrary] })], + providers: [LibraryRepo], + }).compile(); + libraryRepo = module.get(LibraryRepo); + em = module.get(EntityManager); + + const testingLibMetadataVersionOne: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'testing', + majorVersion: 1, + minorVersion: 2, + }; + const testingLibVersionOne = new InstalledLibrary(testingLibMetadataVersionOne); + testingLibVersionOne.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionOne: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'addonVersionOne', + majorVersion: 1, + minorVersion: 2, + }; + const addonLibMetadataVersionOneDuplicate: ILibraryMetadata = { + runnable: false, + title: 'Duplicate', + patchVersion: 3, + machineName: 'addonVersionOne', + majorVersion: 1, + minorVersion: 2, + }; + addonLibVersionOne = new InstalledLibrary(addonLibMetadataVersionOne); + addonLibVersionOne.addTo = { player: { machineNames: [testingLibVersionOne.machineName] } }; + + addonLibVersionOneDuplicate = new InstalledLibrary(addonLibMetadataVersionOneDuplicate); + addonLibVersionOneDuplicate.addTo = { player: { machineNames: [testingLibVersionOne.machineName] } }; + + const testingLibMetadataVersionTwo: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 4, + machineName: 'addonVersionTwo', + majorVersion: 2, + minorVersion: 3, + }; + const testingLibVersionTwo = new InstalledLibrary(testingLibMetadataVersionTwo); + testingLibVersionTwo.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadataVersionTwo: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 4, + machineName: 'addonVersionTwo', + majorVersion: 2, + minorVersion: 3, + }; + addonLibVersionTwo = new InstalledLibrary(addonLibMetadataVersionTwo); + addonLibVersionTwo.addTo = { player: { machineNames: [testingLibVersionTwo.machineName] } }; + + await libraryRepo.createLibrary(addonLibVersionOne); + await libraryRepo.createLibrary(addonLibVersionTwo); + }); + + afterAll(async () => { + await cleanupCollections(em); + await module.close(); + }); + + describe('createLibrary', () => { + it('should save a Library', async () => { + const saveSpy = jest.spyOn(libraryRepo, 'save').mockResolvedValueOnce(undefined); + await libraryRepo.createLibrary(addonLibVersionOne); + expect(saveSpy).toHaveBeenCalledWith(addonLibVersionOne); + saveSpy.mockRestore(); + }); + }); + + describe('getAll', () => { + it('should get all libaries', async () => { + const result = await libraryRepo.getAll(); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + }); + }); + + describe('findByName', () => { + it('should get libaries by name', async () => { + const result = await libraryRepo.findByName('addonVersionTwo'); + expect(result).toBeDefined(); + expect(result).toEqual([addonLibVersionTwo]); + }); + }); + + describe('findOneByNameAndVersionOrFail', () => { + it('should get library', async () => { + const result = await libraryRepo.findOneByNameAndVersionOrFail('addonVersionOne', 1, 2); + expect(result).toBeDefined(); + }); + + it('should throw error', async () => { + try { + await libraryRepo.findOneByNameAndVersionOrFail('notexistinglibrary', 1, 2); + fail('Expected Error'); + } catch (error) { + expect(error).toBeDefined(); + } + }); + it('should throw error', async () => { + try { + await libraryRepo.createLibrary(addonLibVersionOneDuplicate); + await libraryRepo.findOneByNameAndVersionOrFail('addonVersionOne', 1, 2); + fail('Expected Error'); + } catch (error) { + expect(error).toBeDefined(); + expect(error).toEqual(new Error('Multiple libraries with the same name and version found')); + } + }); + }); + + describe('findNewestByNameAndVersion', () => { + it('should get a library by name and version', async () => { + const result = await libraryRepo.findNewestByNameAndVersion('addonVersionTwo', 2, 3); + expect(result).toBeDefined(); + expect(result).toEqual(addonLibVersionTwo); + }); + }); + + describe('findByNameAndExactVersion', () => { + it('should get a library by name and exact version', async () => { + const result = await libraryRepo.findByNameAndExactVersion('addonVersionTwo', 2, 3, 4); + expect(result).toBeDefined(); + expect(result).toEqual(addonLibVersionTwo); + }); + it('should throw error', async () => { + try { + await libraryRepo.findByNameAndExactVersion('addonVersionOne', 1, 2, 3); + fail('Expected Error'); + } catch (error) { + expect(error).toBeDefined(); + expect(error).toEqual(new Error('too many libraries with same name and version')); + } + }); + it('should return null', async () => { + const result = await libraryRepo.findByNameAndExactVersion('addonVersionTwo', 99, 3, 4); + expect(result).toBeDefined(); + expect(result).toEqual(null); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/repo/library.repo.ts b/apps/server/src/modules/h5p-editor/repo/library.repo.ts new file mode 100644 index 00000000000..01aa6eddc4d --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/library.repo.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepo } from '@shared/repo/base.repo'; +import { InstalledLibrary } from '../entity'; + +@Injectable() +export class LibraryRepo extends BaseRepo { + get entityName() { + return InstalledLibrary; + } + + async createLibrary(library: InstalledLibrary): Promise { + const entity = this.create(library); + await this.save(entity); + } + + async getAll(): Promise { + return this._em.find(this.entityName, {}); + } + + async findOneByNameAndVersionOrFail( + machineName: string, + majorVersion: number, + minorVersion: number + ): Promise { + const libs = await this._em.find(this.entityName, { machineName, majorVersion, minorVersion }); + if (libs.length === 1) { + return libs[0]; + } + if (libs.length === 0) { + throw new Error('Library not found'); + } + throw new Error('Multiple libraries with the same name and version found'); + } + + async findByName(machineName: string): Promise { + return this._em.find(this.entityName, { machineName }); + } + + async findNewestByNameAndVersion( + machineName: string, + majorVersion: number, + minorVersion: number + ): Promise { + const libs = await this._em.find(this.entityName, { + machineName, + majorVersion, + minorVersion, + }); + let latest: InstalledLibrary | null = null; + for (const lib of libs) { + if (latest === null || lib.patchVersion > latest.patchVersion) { + latest = lib; + } + } + return latest; + } + + async findByNameAndExactVersion( + machineName: string, + majorVersion: number, + minorVersion: number, + patchVersion: number + ): Promise { + const [libs, count] = await this._em.findAndCount(this.entityName, { + machineName, + majorVersion, + minorVersion, + patchVersion, + }); + if (count > 1) { + throw new Error('too many libraries with same name and version'); + } + if (count === 1) { + return libs[0]; + } + return null; + } +} diff --git a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts new file mode 100644 index 00000000000..ccaff625d66 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts @@ -0,0 +1,127 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections, h5pTemporaryFileFactory } from '@shared/testing'; +import { H5pEditorTempFile } from '../entity'; +import { TemporaryFileRepo } from './temporary-file.repo'; + +describe('TemporaryFileRepo', () => { + let module: TestingModule; + let repo: TemporaryFileRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [H5pEditorTempFile] })], + providers: [TemporaryFileRepo], + }).compile(); + + repo = module.get(TemporaryFileRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(H5pEditorTempFile); + }); + + describe('createTemporaryFile', () => { + it('should be able to retrieve entity', async () => { + const tempFile = h5pTemporaryFileFactory.build(); + await em.persistAndFlush(tempFile); + + const result = await repo.findById(tempFile.id); + + expect(result).toBeDefined(); + expect(result).toEqual(tempFile); + }); + }); + + describe('findByUserAndFilename', () => { + it('should be able to retrieve entity', async () => { + const tempFile = h5pTemporaryFileFactory.build(); + await em.persistAndFlush(tempFile); + + const result = await repo.findByUserAndFilename(tempFile.ownedByUserId, tempFile.filename); + + expect(result).toBeDefined(); + expect(result).toEqual(tempFile); + }); + + it('should fail if entity does not exist', async () => { + const user = 'wrong-user-id'; + const filename = 'file.txt'; + + const findBy = repo.findByUserAndFilename(user, filename); + + await expect(findBy).rejects.toThrow(); + }); + }); + + describe('findAllByUserAndFilename', () => { + it('should be able to retrieve entity', async () => { + const tempFile = h5pTemporaryFileFactory.build(); + await em.persistAndFlush(tempFile); + + const result = await repo.findAllByUserAndFilename(tempFile.ownedByUserId, tempFile.filename); + + expect(result).toBeDefined(); + expect(result).toEqual([tempFile]); + }); + + it('should return empty array', async () => { + const user = 'wrong-user-id'; + const filename = 'file.txt'; + + const findBy = await repo.findAllByUserAndFilename(user, filename); + + expect(findBy).toEqual([]); + }); + }); + + describe('findExpired', () => { + it('should return expired files', async () => { + const [expiredFile, validFile] = [h5pTemporaryFileFactory.isExpired().build(), h5pTemporaryFileFactory.build()]; + await em.persistAndFlush([expiredFile, validFile]); + + const result = await repo.findExpired(); + + expect(result.length).toBe(1); + expect(result[0]).toEqual(expiredFile); + }); + }); + + describe('findByUser', () => { + it('should return files for user', async () => { + const [firstFile, secondFile] = [h5pTemporaryFileFactory.build(), h5pTemporaryFileFactory.build()]; + await em.persistAndFlush([firstFile, secondFile]); + + const result = await repo.findByUser(firstFile.ownedByUserId); + + expect(result.length).toBe(1); + expect(result[0]).toEqual(firstFile); + }); + }); + + describe('findExpiredByUser', () => { + it('should return expired files for user', async () => { + const [firstFile, secondFile] = [ + h5pTemporaryFileFactory.isExpired().build(), + h5pTemporaryFileFactory.isExpired().build(), + ]; + await em.persistAndFlush([firstFile, secondFile]); + + const result = await repo.findExpiredByUser(firstFile.ownedByUserId); + + expect(result.length).toBe(1); + expect(result[0]).toEqual(firstFile); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts new file mode 100644 index 00000000000..ae6966345d9 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { BaseRepo } from '@shared/repo/base.repo'; +import { H5pEditorTempFile } from '../entity'; + +@Injectable() +export class TemporaryFileRepo extends BaseRepo { + get entityName() { + return H5pEditorTempFile; + } + + async findByUserAndFilename(userId: EntityId, filename: string): Promise { + return this._em.findOneOrFail(this.entityName, { ownedByUserId: userId, filename }); + } + + async findAllByUserAndFilename(userId: EntityId, filename: string): Promise { + return this._em.find(this.entityName, { ownedByUserId: userId, filename }); + } + + async findExpired(): Promise { + const now = new Date(); + return this._em.find(this.entityName, { expiresAt: { $lt: now } }); + } + + async findByUser(userId: EntityId): Promise { + return this._em.find(this.entityName, { ownedByUserId: userId }); + } + + async findExpiredByUser(userId: EntityId): Promise { + const now = new Date(); + return this._em.find(this.entityName, { $and: [{ ownedByUserId: userId }, { expiresAt: { $lt: now } }] }); + } +} diff --git a/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts b/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts new file mode 100644 index 00000000000..f9c8063dffd --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts @@ -0,0 +1,27 @@ +import { H5PConfig, UrlGenerator } from '@lumieducation/h5p-server'; + +const API_BASE = '/api/v3/h5p-editor'; +const STATIC_FILES_BASE = '/h5pstatics'; + +export const h5pConfig = new H5PConfig(undefined, { + baseUrl: '', + + ajaxUrl: `${API_BASE}/ajax`, + contentFilesUrl: `${API_BASE}/content`, + contentFilesUrlPlayerOverride: undefined, + contentUserDataUrl: `${API_BASE}/contentUserData`, + downloadUrl: undefined, + librariesUrl: `${API_BASE}/libraries`, + paramsUrl: `${API_BASE}/params`, + playUrl: `${API_BASE}/play`, + setFinishedUrl: `${API_BASE}/finishedData`, + temporaryFilesUrl: `${API_BASE}/temp-files`, + + coreUrl: `${STATIC_FILES_BASE}/core`, + editorLibraryUrl: `${STATIC_FILES_BASE}/editor`, + + contentUserStateSaveInterval: false, + setFinishedEnabled: false, +}); + +export const h5pUrlGenerator = new UrlGenerator(h5pConfig); diff --git a/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts b/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts new file mode 100644 index 00000000000..a02c8867cd0 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts @@ -0,0 +1,928 @@ +import { HeadObjectCommandOutput } from '@aws-sdk/client-s3'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { IContentMetadata, ILibraryName, IUser, LibraryName } from '@lumieducation/h5p-server'; +import { HttpException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { IEntity } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { ObjectID } from 'bson'; +import { Readable } from 'stream'; +import { GetH5PFileResponse } from '../controller/dto'; +import { H5PContent, H5PContentParentType, IH5PContentProperties } from '../entity'; +import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; +import { H5PContentRepo } from '../repo'; +import { H5PContentParentParams, LumiUserWithContentData } from '../types/lumi-types'; +import { ContentStorage } from './contentStorage.service'; + +const helpers = { + buildMetadata( + title: string, + mainLibrary: string, + preloadedDependencies: ILibraryName[] = [], + dynamicDependencies?: ILibraryName[], + editorDependencies?: ILibraryName[] + ): IContentMetadata { + return { + defaultLanguage: 'de-DE', + license: 'Unlicensed', + title, + dynamicDependencies, + editorDependencies, + embedTypes: ['iframe'], + language: 'de-DE', + mainLibrary, + preloadedDependencies, + }; + }, + + buildContent(n = 0) { + const metadata = helpers.buildMetadata(`Content #${n}`, `Library-${n}.0`); + const content = { + data: `Data #${n}`, + }; + const h5pContentProperties: IH5PContentProperties = { + creatorId: new ObjectID().toString(), + parentId: new ObjectID().toString(), + schoolId: new ObjectID().toString(), + metadata, + content, + parentType: H5PContentParentType.Lesson, + }; + const h5pContent = new H5PContent(h5pContentProperties); + + return { + withID(id?: number) { + const objectId = new ObjectID(id); + h5pContent._id = objectId; + h5pContent.id = objectId.toString(); + + return h5pContent; + }, + new() { + return h5pContent; + }, + }; + }, + + createUser() { + return { + canCreateRestricted: false, + canInstallRecommended: false, + canUpdateAndInstallLibraries: false, + email: 'example@schul-cloud.org', + id: '12345', + name: 'Example User', + type: 'user', + }; + }, + + repoSaveMock: async (entities: Entity | Entity[]) => { + if (!Array.isArray(entities)) { + entities = [entities]; + } + + for (const entity of entities) { + if (!entity._id) { + const id = new ObjectID(); + entity._id = id; + entity.id = id.toString(); + } + } + + return Promise.resolve(); + }, +}; + +describe('ContentStorage', () => { + let module: TestingModule; + let service: ContentStorage; + let s3ClientAdapter: DeepMocked; + let contentRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ContentStorage, + { provide: H5PContentRepo, useValue: createMock() }, + { provide: H5P_CONTENT_S3_CONNECTION, useValue: createMock() }, + ], + }).compile(); + + service = module.get(ContentStorage); + s3ClientAdapter = module.get(H5P_CONTENT_S3_CONNECTION); + contentRepo = module.get(H5PContentRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('addContent', () => { + const setup = () => { + const newContent = helpers.buildContent(0).new(); + const existingContent = helpers.buildContent(0).withID(); + + const iUser: IUser = { + canCreateRestricted: false, + canInstallRecommended: false, + canUpdateAndInstallLibraries: false, + email: 'example@schul-cloud.org', + id: new ObjectID().toHexString(), + name: 'Example User', + type: 'user', + }; + const parentParams: H5PContentParentParams = { + schoolId: new ObjectID().toHexString(), + parentType: H5PContentParentType.Lesson, + parentId: new ObjectID().toHexString(), + }; + const user = new LumiUserWithContentData(iUser, parentParams); + + return { newContent, existingContent, user }; + }; + + describe('WHEN adding new content', () => { + it('should call H5pContentRepo.save', async () => { + const { + newContent: { metadata, content }, + user, + } = setup(); + + await service.addContent(metadata, content, user); + + expect(contentRepo.save).toHaveBeenCalledWith(expect.objectContaining({ metadata, content })); + }); + + it('should return content id', async () => { + const { + newContent: { metadata, content }, + user, + } = setup(); + contentRepo.save.mockImplementationOnce(helpers.repoSaveMock); + + const id = await service.addContent(metadata, content, user); + + expect(id).toBeDefined(); + }); + }); + + describe('WHEN modifying existing content', () => { + it('should call H5pContentRepo.save', async () => { + const { + existingContent, + newContent: { metadata, content }, + user, + } = setup(); + contentRepo.findById.mockResolvedValueOnce(existingContent); + + await service.addContent(metadata, content, user, existingContent.id); + + expect(contentRepo.save).toHaveBeenCalledWith(expect.objectContaining({ metadata, content })); + }); + + it('should save content and return existing content id', async () => { + const { + existingContent, + newContent: { metadata, content }, + user, + } = setup(); + const oldId = existingContent.id; + contentRepo.save.mockImplementationOnce(helpers.repoSaveMock); + contentRepo.findById.mockResolvedValueOnce(existingContent); + + const newId = await service.addContent(metadata, content, user, oldId); + + expect(newId).toEqual(oldId); + expect(existingContent).toEqual(expect.objectContaining({ metadata, content })); + }); + }); + + describe('WHEN saving content fails', () => { + it('should throw an HttpException', async () => { + const { + existingContent: { metadata, content }, + user, + } = setup(); + contentRepo.save.mockRejectedValueOnce(new Error()); + + const addContentPromise = service.addContent(metadata, content, user); + + await expect(addContentPromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN finding content fails', () => { + it('should throw an HttpException', async () => { + const { + existingContent: { metadata, content, id }, + user, + } = setup(); + contentRepo.findById.mockRejectedValueOnce(new Error()); + + const addContentPromise = service.addContent(metadata, content, user, id); + + await expect(addContentPromise).rejects.toThrow(HttpException); + }); + }); + }); + + describe('addFile', () => { + const setup = () => { + const filename = 'filename.txt'; + const stream = Readable.from('content'); + + const contentID = new ObjectID(); + const contentIDString = contentID.toString(); + + const user = helpers.createUser(); + + const fileCreateError = new Error('Could not create file'); + + return { + filename, + stream, + contentID, + contentIDString, + user, + fileCreateError, + }; + }; + + describe('WHEN adding a file to existing content', () => { + it('should check if the content exists', async () => { + const { contentIDString, filename, stream } = setup(); + + await service.addFile(contentIDString, filename, stream); + + expect(contentRepo.existsOne).toBeCalledWith(contentIDString); + }); + + it('should call S3ClientAdapter.create', async () => { + const { contentIDString, filename, stream } = setup(); + contentRepo.existsOne.mockResolvedValueOnce(true); + + await service.addFile(contentIDString, filename, stream); + + expect(s3ClientAdapter.create).toBeCalledWith( + expect.stringContaining(filename), + expect.objectContaining({ + name: filename, + data: stream, + mimeType: 'application/json', + }) + ); + }); + }); + + describe('WHEN adding a file to non existant content', () => { + it('should throw NotFoundException', async () => { + const { contentIDString, filename, stream } = setup(); + contentRepo.findById.mockRejectedValueOnce(new Error()); + + const addFilePromise = service.addFile(contentIDString, filename, stream); + + await expect(addFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN S3ClientAdapter throws error', () => { + it('should throw the error', async () => { + const { contentIDString, filename, stream, fileCreateError } = setup(); + contentRepo.existsOne.mockResolvedValueOnce(true); + s3ClientAdapter.create.mockRejectedValueOnce(fileCreateError); + + const addFilePromise = service.addFile(contentIDString, filename, stream); + + await expect(addFilePromise).rejects.toBe(fileCreateError); + }); + }); + + describe('WHEN content id is empty string', () => { + it('should throw error', async () => { + const { filename, stream } = setup(); + + const addFilePromise = service.addFile('', filename, stream); + + await expect(addFilePromise).rejects.toThrow(); + }); + }); + }); + + describe('contentExists', () => { + describe('WHEN content does exist', () => { + it('should return true', async () => { + const content = helpers.buildContent().withID(); + contentRepo.existsOne.mockResolvedValueOnce(true); + + const exists = await service.contentExists(content.id); + + expect(exists).toBe(true); + }); + }); + + describe('WHEN content does not exist', () => { + it('should return false', async () => { + contentRepo.existsOne.mockResolvedValueOnce(false); + + const exists = await service.contentExists(''); + + expect(exists).toBe(false); + }); + }); + }); + + describe('deleteContent', () => { + const setup = () => { + const content = helpers.buildContent().withID(); + + const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt']; + + const user = helpers.createUser(); + // @ts-expect-error test case + s3ClientAdapter.list.mockResolvedValueOnce({ files }); + + return { + content, + files, + user, + }; + }; + + describe('WHEN content exists', () => { + it('should call H5PContentRepo.delete', async () => { + const { content } = setup(); + contentRepo.findById.mockResolvedValueOnce(content); + contentRepo.existsOne.mockResolvedValueOnce(true); + + await service.deleteContent(content.id); + + expect(contentRepo.delete).toHaveBeenCalledWith(content); + }); + + it('should call S3ClientAdapter.deleteFile for every file', async () => { + const { content, files } = setup(); + contentRepo.findById.mockResolvedValueOnce(content); + contentRepo.existsOne.mockResolvedValueOnce(true); + + await service.deleteContent(content.id); + + for (const file of files) { + expect(s3ClientAdapter.delete).toHaveBeenCalledWith([expect.stringContaining(file)]); + } + }); + }); + + describe('WHEN content does not exist', () => { + it('should throw HttpException', async () => { + const { content } = setup(); + contentRepo.findById.mockRejectedValueOnce(new Error()); + + const deletePromise = service.deleteContent(content.id); + + await expect(deletePromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN H5PContentRepo.delete throws an error', () => { + it('should throw HttpException', async () => { + const { content } = setup(); + contentRepo.delete.mockRejectedValueOnce(new Error()); + + const deletePromise = service.deleteContent(content.id); + + await expect(deletePromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN S3ClientAdapter.delete throws an error', () => { + it('should throw HttpException', async () => { + const { content } = setup(); + s3ClientAdapter.delete.mockRejectedValueOnce(new Error()); + + const deletePromise = service.deleteContent(content.id); + + await expect(deletePromise).rejects.toThrow(HttpException); + }); + }); + }); + + describe('deleteFile', () => { + const setup = () => { + const filename = 'file.txt'; + const invalidFilename = '..test.txt'; + + const user = helpers.createUser(); + + const deleteError = new Error('Could not delete'); + + const contentID = new ObjectID().toString(); + + return { + contentID, + deleteError, + filename, + invalidFilename, + user, + }; + }; + + describe('WHEN deleting a file', () => { + it('should call S3ClientAdapter.delete', async () => { + const { contentID, filename } = setup(); + + await service.deleteFile(contentID, filename); + + expect(s3ClientAdapter.delete).toHaveBeenCalledWith([expect.stringContaining(contentID)]); + }); + }); + + describe('WHEN filename is invalid', () => { + it('should throw error', async () => { + const { contentID, invalidFilename } = setup(); + + const deletePromise = service.deleteFile(contentID, invalidFilename); + + await expect(deletePromise).rejects.toThrow(); + }); + }); + + describe('WHEN S3ClientAdapter throws an error', () => { + it('should throw along the error', async () => { + const { contentID, filename, deleteError } = setup(); + s3ClientAdapter.delete.mockRejectedValueOnce(deleteError); + + const deletePromise = service.deleteFile(contentID, filename); + + await expect(deletePromise).rejects.toBe(deleteError); + }); + }); + }); + + describe('fileExists', () => { + const setup = () => { + const filename = 'file.txt'; + const invalidFilename = '..test.txt'; + + const deleteError = new Error('Could not delete'); + + const contentID = new ObjectID().toString(); + + return { + contentID, + deleteError, + filename, + invalidFilename, + }; + }; + + describe('WHEN file exists', () => { + it('should return true', async () => { + const { contentID, filename } = setup(); + s3ClientAdapter.head.mockResolvedValueOnce(createMock()); + + const exists = await service.fileExists(contentID, filename); + + expect(exists).toBe(true); + }); + }); + + describe('WHEN file does not exist', () => { + it('should return false', async () => { + const { contentID, filename } = setup(); + // s3ClientAdapter.head.mockRejectedValueOnce(new NotFoundException('NoSuchKey')); + s3ClientAdapter.get.mockRejectedValue(new NotFoundException('NoSuchKey')); + + const exists = await service.fileExists(contentID, filename); + + expect(exists).toBe(false); + }); + }); + + describe('WHEN S3ClientAdapter.head throws error', () => { + it('should throw HttpException', async () => { + const { contentID, filename } = setup(); + s3ClientAdapter.get.mockRejectedValueOnce(new Error()); + + const existsPromise = service.fileExists(contentID, filename); + + await expect(existsPromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN filename is invalid', () => { + it('should throw error', async () => { + const { contentID, invalidFilename } = setup(); + + const existsPromise = service.fileExists(contentID, invalidFilename); + + await expect(existsPromise).rejects.toThrow(); + }); + }); + }); + + describe('getFileStats', () => { + const setup = () => { + const filename = 'file.txt'; + + const user = helpers.createUser(); + + const contentID = new ObjectID().toString(); + + const birthtime = new Date(); + const size = 100; + + const headResponse = createMock({ + ContentLength: size, + LastModified: birthtime, + }); + + const headResponseWithoutContentLength = createMock({ + ContentLength: undefined, + LastModified: birthtime, + }); + + const headResponseWithoutLastModified = createMock({ + ContentLength: size, + LastModified: undefined, + }); + + const headError = new Error('Head'); + + return { + size, + birthtime, + contentID, + filename, + user, + headResponse, + headResponseWithoutContentLength, + headResponseWithoutLastModified, + headError, + }; + }; + + describe('WHEN file exists', () => { + it('should return file stats', async () => { + const { filename, contentID, headResponse, size, birthtime } = setup(); + s3ClientAdapter.head.mockResolvedValueOnce(headResponse); + + const stats = await service.getFileStats(contentID, filename); + + expect(stats).toEqual( + expect.objectContaining({ + birthtime, + size, + }) + ); + }); + }); + + describe('WHEN response from S3 is missing ContentLength field', () => { + it('should throw InternalServerError', async () => { + const { filename, contentID, headResponseWithoutContentLength } = setup(); + s3ClientAdapter.head.mockResolvedValueOnce(headResponseWithoutContentLength); + + const statsPromise = service.getFileStats(contentID, filename); + + await expect(statsPromise).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('WHEN response from S3 is missing LastModified field', () => { + it('should throw InternalServerError', async () => { + const { filename, contentID, headResponseWithoutLastModified } = setup(); + s3ClientAdapter.head.mockResolvedValueOnce(headResponseWithoutLastModified); + + const statsPromise = service.getFileStats(contentID, filename); + + await expect(statsPromise).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('WHEN S3ClientAdapter.head throws error', () => { + it('should throw the error', async () => { + const { filename, contentID, headError } = setup(); + s3ClientAdapter.head.mockRejectedValueOnce(headError); + + const statsPromise = service.getFileStats(contentID, filename); + + await expect(statsPromise).rejects.toBe(headError); + }); + }); + }); + + describe('getFileStream', () => { + const setup = () => { + const filename = 'testfile.txt'; + const fileStream = Readable.from('content'); + const contentID = new ObjectID().toString(); + const fileResponse = createMock({ data: fileStream }); + const user = helpers.createUser(); + + const getError = new Error('Could not get file'); + + // [start, end, expected range] + const testRanges = [ + [undefined, undefined, '0-'], + [100, undefined, '100-'], + [undefined, 100, '0-100'], + [100, 999, '100-999'], + ] as const; + + return { filename, contentID, fileStream, fileResponse, testRanges, user, getError }; + }; + + describe('WHEN file exists', () => { + it('should S3ClientAdapter.get with range', async () => { + const { testRanges, contentID, filename, user, fileResponse } = setup(); + + for (const range of testRanges) { + s3ClientAdapter.get.mockResolvedValueOnce(fileResponse); + + // eslint-disable-next-line no-await-in-loop + await service.getFileStream(contentID, filename, user, range[0], range[1]); + + expect(s3ClientAdapter.get).toHaveBeenCalledWith(expect.stringContaining(filename), range[2]); + } + }); + + it('should return stream from S3ClientAdapter', async () => { + const { fileStream, contentID, filename, user, fileResponse } = setup(); + s3ClientAdapter.get.mockResolvedValueOnce(fileResponse); + + const stream = await service.getFileStream(contentID, filename, user); + + expect(stream).toBe(fileStream); + }); + }); + + describe('WHEN S3ClientAdapter.get throws error', () => { + it('should throw the error', async () => { + const { contentID, filename, user, getError } = setup(); + s3ClientAdapter.get.mockRejectedValueOnce(getError); + + const streamPromise = service.getFileStream(contentID, filename, user); + + await expect(streamPromise).rejects.toBe(getError); + }); + }); + }); + + describe('getMetadata', () => { + const setup = () => { + const content = helpers.buildContent().withID(); + const { id } = content; + const error = new Error('Content not found'); + + const user = helpers.createUser(); + + return { content, id, user, error }; + }; + + describe('WHEN content exists', () => { + it('should return metadata', async () => { + const { content, id } = setup(); + contentRepo.findById.mockResolvedValueOnce(content); + + const metadata = await service.getMetadata(id); + + expect(metadata).toEqual(content.metadata); + }); + }); + + describe('WHEN content does not exist', () => { + it('should throw error', async () => { + const { id, error } = setup(); + contentRepo.findById.mockRejectedValueOnce(error); + + const metadataPromise = service.getMetadata(id); + + await expect(metadataPromise).rejects.toBe(error); + }); + }); + }); + + describe('getParameters', () => { + const setup = () => { + const content = helpers.buildContent().withID(); + const { id } = content; + const error = new Error('Content not found'); + + const user = helpers.createUser(); + + return { content, id, user, error }; + }; + + describe('WHEN content exists', () => { + it('should return parameters', async () => { + const { content, id } = setup(); + contentRepo.findById.mockResolvedValueOnce(content); + + const parameters = await service.getParameters(id); + + expect(parameters).toEqual(content.content); + }); + }); + + describe('WHEN content does not exist', () => { + it('should throw error', async () => { + const { id, error } = setup(); + contentRepo.findById.mockRejectedValueOnce(error); + + const parametersPromise = service.getParameters(id); + + await expect(parametersPromise).rejects.toBe(error); + }); + }); + }); + + describe('listContent', () => { + const setup = () => { + const getContentsResponse = [1, 2, 3, 4].map((id) => helpers.buildContent().withID(id)); + const contentIds = getContentsResponse.map((content) => content.id); + + const error = new Error('could not list entities'); + + const user = helpers.createUser(); + + return { getContentsResponse, contentIds, user, error }; + }; + + describe('WHEN querying for contents', () => { + it('should return list of IDs', async () => { + const { contentIds, getContentsResponse } = setup(); + contentRepo.getAllContents.mockResolvedValueOnce(getContentsResponse); + + const ids = await service.listContent(); + + expect(ids).toEqual(contentIds); + }); + }); + + describe('WHEN H5PContentRepo.getAllContents throws error', () => { + it('should throw the error', async () => { + const { error } = setup(); + contentRepo.getAllContents.mockRejectedValueOnce(error); + + const listPromise = service.listContent(); + + await expect(listPromise).rejects.toBe(error); + }); + }); + }); + + describe('listFiles', () => { + const setup = () => { + const content = helpers.buildContent().withID(); + const user = helpers.createUser(); + const filenames = ['1.txt', '2.txt']; + const error = new Error('error occured'); + + return { content, filenames, user, error }; + }; + + describe('WHEN content exists', () => { + it('should return list of filenames', async () => { + const { filenames, content } = setup(); + contentRepo.existsOne.mockResolvedValueOnce(true); + // @ts-expect-error test case + s3ClientAdapter.list.mockResolvedValueOnce({ files: filenames }); + + const files = await service.listFiles(content.id); + + expect(files).toEqual(filenames); + }); + }); + + describe('WHEN content does not exist', () => { + it('should throw HttpException', async () => { + const { content } = setup(); + contentRepo.existsOne.mockResolvedValueOnce(false); + + const listPromise = service.listFiles(content.id); + + await expect(listPromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN S3ClientAdapter.list throws error', () => { + it('should throw the error', async () => { + const { content, error } = setup(); + contentRepo.existsOne.mockResolvedValueOnce(true); + s3ClientAdapter.list.mockRejectedValueOnce(error); + + const listPromise = service.listFiles(content.id); + + await expect(listPromise).rejects.toBe(error); + }); + }); + + describe('WHEN ID is empty string', () => { + it('should throw error', async () => { + const listPromise = service.listFiles(''); + + await expect(listPromise).rejects.toThrow(); + }); + }); + }); + + describe('getUsage', () => { + const setup = () => { + const library = 'TEST.Library-1.0'; + const libraryName = LibraryName.fromUberName(library); + + const contentMain = helpers.buildContent(0).withID(0); + const content1 = helpers.buildContent(1).withID(1); + const content2 = helpers.buildContent(2).withID(2); + const content3 = helpers.buildContent(3).withID(3); + const content4 = helpers.buildContent(4).withID(4); + + contentMain.metadata.mainLibrary = libraryName.machineName; + contentMain.metadata.preloadedDependencies = [libraryName]; + content1.metadata.preloadedDependencies = [libraryName]; + content2.metadata.editorDependencies = [libraryName]; + content3.metadata.dynamicDependencies = [libraryName]; + + const contents = [contentMain, content1, content2, content3, content4]; + + const findByIdMock = async (id: string) => { + const content = contents.find((c) => c.id === id); + + if (content) { + return Promise.resolve(content); + } + + throw new Error('Not found'); + }; + + const expectedUsage = { asDependency: 3, asMainLibrary: 1 }; + + return { libraryName, findByIdMock, contents, expectedUsage }; + }; + + it('should return the number of times the library is used', async () => { + const { libraryName, contents, findByIdMock, expectedUsage } = setup(); + contentRepo.findById.mockImplementation(findByIdMock); // Will be called multiple times + contentRepo.getAllContents.mockResolvedValueOnce(contents); + + const test = await service.getUsage(libraryName); + + expect(test).toEqual(expectedUsage); + }); + }); + + describe('getUserPermissions (currently unused)', () => { + it('should return array of permissions', async () => { + // const user = helpers.createUser(); + + // This method is currently unused and will be changed later + const permissions = await service.getUserPermissions(); + + expect(permissions.length).toBeGreaterThan(0); + }); + }); + + describe('private methods', () => { + describe('WHEN calling getContentPath with invalid parameters', () => { + it('should throw error', async () => { + // Test private getContentPath using listFiles + contentRepo.existsOne.mockResolvedValueOnce(true); + const promise = service.listFiles(''); + await expect(promise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN calling getFilePath with invalid parameters', () => { + it('should throw error', async () => { + // Test private getFilePath using fileExists + const missingContentID = service.fileExists('', 'filename'); + await expect(missingContentID).rejects.toThrow(HttpException); + + const missingFilename = service.fileExists('id', ''); + await expect(missingFilename).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN calling checkFilename with invalid parameters', () => { + it('should throw error', async () => { + // Test private checkFilename using deleteFile + const invalidChars = service.deleteFile('id', 'ex#ample.txt'); + await expect(invalidChars).rejects.toThrow(HttpException); + + const includesDoubleDot = service.deleteFile('id', '../test.txt'); + await expect(includesDoubleDot).rejects.toThrow(HttpException); + + const startsWithSlash = service.deleteFile('id', '/example.txt'); + await expect(startsWithSlash).rejects.toThrow(HttpException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts new file mode 100644 index 00000000000..95895b867c8 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts @@ -0,0 +1,305 @@ +import { + ContentId, + IContentMetadata, + IContentStorage, + IFileStats, + ILibraryName, + IUser as ILumiUser, + LibraryName, + Permission, +} from '@lumieducation/h5p-server'; +import { + HttpException, + Inject, + Injectable, + InternalServerErrorException, + NotAcceptableException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { ErrorUtils } from '@src/core/error/utils'; +import { Readable } from 'stream'; +import { H5pFileDto } from '../controller/dto/h5p-file.dto'; +import { H5PContent } from '../entity'; +import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; +import { H5PContentRepo } from '../repo'; +import { LumiUserWithContentData } from '../types/lumi-types'; + +@Injectable() +export class ContentStorage implements IContentStorage { + constructor( + private readonly repo: H5PContentRepo, + @Inject(H5P_CONTENT_S3_CONNECTION) private readonly storageClient: S3ClientAdapter + ) {} + + private async createOrUpdateContent( + contentId: ContentId, + user: LumiUserWithContentData, + metadata: IContentMetadata, + content: unknown + ): Promise { + let h5pContent: H5PContent; + + if (contentId) { + h5pContent = await this.repo.findById(contentId); + h5pContent.metadata = metadata; + h5pContent.content = content; + } else { + h5pContent = new H5PContent({ + parentType: user.contentParentType, + parentId: user.contentParentId, + creatorId: user.id, + schoolId: user.schoolId, + metadata, + content, + }); + } + return h5pContent; + } + + public async addContent( + metadata: IContentMetadata, + content: unknown, + user: LumiUserWithContentData, + contentId?: ContentId | undefined + ): Promise { + try { + const h5pContent = await this.createOrUpdateContent(contentId as string, user, metadata, content); + await this.repo.save(h5pContent); + + return h5pContent.id; + } catch (error) { + throw new HttpException('message', 500, { + cause: new InternalServerErrorException(error as string, 'ContentStorage:addContent'), + }); + } + } + + public async addFile(contentId: string, filename: string, stream: Readable): Promise { + this.checkFilename(filename); + + const contentExists = await this.contentExists(contentId); + if (!contentExists) { + throw new NotFoundException('The content does not exist'); + } + + const fullPath = this.getFilePath(contentId, filename); + const file: H5pFileDto = { + name: filename, + data: stream, + mimeType: 'application/json', + }; + + await this.storageClient.create(fullPath, file); + } + + public async contentExists(contentId: string): Promise { + const exists = await this.repo.existsOne(contentId); + + return exists; + } + + public async deleteContent(contentId: string): Promise { + try { + const h5pContent = await this.repo.findById(contentId); + + const fileList = await this.listFiles(contentId); + const fileDeletePromises = fileList.map((file) => this.deleteFile(contentId, file)); + + await Promise.all([this.repo.delete(h5pContent), ...fileDeletePromises]); + } catch (error) { + throw new HttpException('message', 500, { + cause: new InternalServerErrorException(error as string, 'ContentStorage:addContent'), + }); + } + } + + public async deleteFile(contentId: string, filename: string): Promise { + this.checkFilename(filename); + const filePath = this.getFilePath(contentId, filename); + await this.storageClient.delete([filePath]); + } + + public async fileExists(contentId: string, filename: string): Promise { + this.checkFilename(filename); + + const filePath = this.getFilePath(contentId, filename); + + return this.exists(filePath); + } + + public async getFileStats(contentId: string, file: string): Promise { + const filePath = this.getFilePath(contentId, file); + const { ContentLength, LastModified } = await this.storageClient.head(filePath); + + if (ContentLength === undefined || LastModified === undefined) { + throw new InternalServerErrorException( + { ContentLength, LastModified }, + 'ContentStorage:getFileStats ContentLength or LastModified are undefined' + ); + } + + const fileStats: IFileStats = { + birthtime: LastModified, + size: ContentLength, + }; + + return fileStats; + } + + public async getFileStream( + contentId: string, + file: string, + _user: ILumiUser, + rangeStart = 0, + rangeEnd?: number + ): Promise { + const filePath = this.getFilePath(contentId, file); + + let range: string; + if (rangeEnd === undefined) { + // Open ended range + range = `${rangeStart}-`; + } else { + // Closed range + range = `${rangeStart}-${rangeEnd}`; + } + + const fileResponse = await this.storageClient.get(filePath, range); + return fileResponse.data; + } + + public async getMetadata(contentId: string): Promise { + const h5pContent = await this.repo.findById(contentId); + return h5pContent.metadata; + } + + public async getParameters(contentId: string): Promise { + const h5pContent = await this.repo.findById(contentId); + return h5pContent.content; + } + + public async getUsage(library: ILibraryName): Promise<{ asDependency: number; asMainLibrary: number }> { + const contentIds = await this.listContent(); + const result = await this.resolveDependecies(contentIds, library); + return result; + } + + public getUserPermissions(): Promise { + const permissions = [Permission.Delete, Permission.Download, Permission.Edit, Permission.Embed, Permission.View]; + + return Promise.resolve(permissions); + } + + public async listContent(): Promise { + const contentList = await this.repo.getAllContents(); + + const contentIDs = contentList.map((c) => c.id); + return contentIDs; + } + + public async listFiles(contentId: string): Promise { + const contentExists = await this.contentExists(contentId); + if (!contentExists) { + throw new HttpException('message', 404, { + cause: new NotFoundException('Content could not be found'), + }); + } + + const path = this.getContentPath(contentId); + const { files } = await this.storageClient.list({ path }); + + return files; + } + + private async exists(checkPath: string): Promise { + try { + await this.storageClient.get(checkPath); + } catch (err) { + if (err instanceof NotFoundException) { + return false; + } + + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'ContentStorage:addContent') + ); + } + + return true; + } + + private hasDependencyOn( + metadata: { + dynamicDependencies?: ILibraryName[]; + editorDependencies?: ILibraryName[]; + preloadedDependencies: ILibraryName[]; + }, + library: ILibraryName + ): boolean { + if ( + metadata.preloadedDependencies.some((dep) => LibraryName.equal(dep, library)) || + metadata.editorDependencies?.some((dep) => LibraryName.equal(dep, library)) || + metadata.dynamicDependencies?.some((dep) => LibraryName.equal(dep, library)) + ) { + return true; + } + return false; + } + + private async resolveDependecies( + contentIds: string[], + library: ILibraryName + ): Promise<{ asMainLibrary: number; asDependency: number }> { + let asDependency = 0; + let asMainLibrary = 0; + + const contentMetadataList = await Promise.all(contentIds.map((id) => this.getMetadata(id))); + + for (const contentMetadata of contentMetadataList) { + const isMainLibrary = contentMetadata.mainLibrary === library.machineName; + if (this.hasDependencyOn(contentMetadata, library)) { + if (isMainLibrary) { + asMainLibrary += 1; + } else { + asDependency += 1; + } + } + } + + return { asMainLibrary, asDependency }; + } + + private checkFilename(filename: string): void { + filename = filename.split('.').slice(0, -1).join('.'); + if (/^[a-zA-Z0-9/._-]*$/.test(filename) && !filename.includes('..') && !filename.startsWith('/')) { + return; + } + throw new HttpException('message', 406, { + cause: new NotAcceptableException(`Filename contains forbidden characters ${filename}`), + }); + } + + private getContentPath(contentId: string): string { + if (!contentId) { + throw new HttpException('message', 406, { + cause: new UnprocessableEntityException('COULD_NOT_CREATE_PATH'), + }); + } + + const path = `h5p-content/${contentId}/`; + return path; + } + + private getFilePath(contentId: string, filename: string): string { + if (!contentId || !filename) { + throw new HttpException('message', 406, { + cause: new UnprocessableEntityException('COULD_NOT_CREATE_PATH'), + }); + } + + const path = `${this.getContentPath(contentId)}${filename}`; + return path; + } +} diff --git a/apps/server/src/modules/h5p-editor/service/h5p-translator.service.ts b/apps/server/src/modules/h5p-editor/service/h5p-translator.service.ts new file mode 100644 index 00000000000..0da03a6866f --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/h5p-translator.service.ts @@ -0,0 +1,34 @@ +import { ITranslationFunction } from '@lumieducation/h5p-server'; +import i18next from 'i18next'; +import i18nextFsBackend from 'i18next-fs-backend'; +import path from 'path'; +import { translatorConfig } from '../h5p-editor.config'; + +export const Translator = { + async translate() { + const lumiPackagePath = path.dirname(require.resolve('@lumieducation/h5p-server/package.json')); + const pathBackend = path.join(lumiPackagePath, 'build/assets/translations/{{ns}}/{{lng}}.json'); + + const translationFunction = await i18next.use(i18nextFsBackend).init({ + backend: { + loadPath: pathBackend, + }, + ns: [ + 'client', + 'copyright-semantics', + 'hub', + 'library-metadata', + 'metadata-semantics', + 'mongo-s3-content-storage', + 's3-temporary-storage', + 'server', + 'storage-file-implementations', + ], + preload: translatorConfig.AVAILABLE_LANGUAGES, + }); + + const translate: ITranslationFunction = (key, language) => translationFunction(key, { lng: language }); + + return translate; + }, +}; diff --git a/apps/server/src/modules/h5p-editor/service/index.ts b/apps/server/src/modules/h5p-editor/service/index.ts new file mode 100644 index 00000000000..d3d93b55fee --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/index.ts @@ -0,0 +1,4 @@ +export * from './contentStorage.service'; +export * from './libraryStorage.service'; +export * from './temporary-file-storage.service'; +export * from './h5p-translator.service'; diff --git a/apps/server/src/modules/h5p-editor/service/libraryStorage.service.spec.ts b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.spec.ts new file mode 100644 index 00000000000..afde26d59b0 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.spec.ts @@ -0,0 +1,765 @@ +import { Readable } from 'stream'; + +import { HeadObjectCommandOutput, ServiceOutputTypes } from '@aws-sdk/client-s3'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5pError, ILibraryMetadata, ILibraryName } from '@lumieducation/h5p-server'; +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { FileMetadata, InstalledLibrary } from '../entity/library.entity'; +import { H5P_LIBRARIES_S3_CONNECTION } from '../h5p-editor.config'; +import { LibraryRepo } from '../repo/library.repo'; +import { LibraryStorage } from './libraryStorage.service'; + +async function readStream(stream: Readable): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chunks: any[] = []; + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + }); +} + +jest.useFakeTimers(); +describe('LibraryStorage', () => { + let module: TestingModule; + let storage: LibraryStorage; + let s3ClientAdapter: DeepMocked; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LibraryStorage, + { + provide: LibraryRepo, + useValue: createMock(), + }, + { provide: H5P_LIBRARIES_S3_CONNECTION, useValue: createMock() }, + ], + }).compile(); + + storage = module.get(LibraryStorage); + s3ClientAdapter = module.get(H5P_LIBRARIES_S3_CONNECTION); + repo = module.get(LibraryRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + + const installedLibs: InstalledLibrary[] = []; + + repo.getAll.mockImplementation(() => { + const libs: InstalledLibrary[] = []; + for (const lib of installedLibs) { + libs.push(lib); + } + return Promise.resolve(libs); + }); + + repo.findByName.mockImplementation((machineName) => { + const libs: InstalledLibrary[] = []; + for (const lib of installedLibs) { + if (lib.machineName === machineName) { + libs.push(lib); + } + } + return Promise.resolve(libs); + }); + + repo.findByNameAndExactVersion.mockImplementation((machName, major, minor, patch) => { + for (const lib of installedLibs) { + if ( + lib.machineName === machName && + lib.majorVersion === major && + lib.minorVersion === minor && + lib.patchVersion === patch + ) { + return Promise.resolve(lib); + } + } + return Promise.resolve(null); + }); + + repo.findNewestByNameAndVersion.mockImplementation((machName, major, minor) => { + let latest: InstalledLibrary | null = null; + for (const lib of installedLibs) { + if ( + lib.machineName === machName && + lib.majorVersion === major && + lib.minorVersion === minor && + (latest === null || lib.patchVersion > latest.patchVersion) + ) { + latest = lib; + } + } + return Promise.resolve(latest); + }); + + repo.findOneByNameAndVersionOrFail.mockImplementation((machName, major, minor) => { + const libs: InstalledLibrary[] = []; + for (const lib of installedLibs) { + if (lib.machineName === machName && lib.majorVersion === major && lib.minorVersion === minor) { + libs.push(lib); + } + } + if (libs.length === 1) { + return Promise.resolve(libs[0]); + } + if (libs.length === 0) { + throw new Error('Library not found'); + } + throw new Error('Multiple libraries with the same name and version found'); + }); + + repo.createLibrary.mockImplementation((lib) => { + installedLibs.push(lib); + return Promise.resolve(); + }); + + repo.save.mockImplementation((lib) => { + if ('concat' in lib) { + throw Error('Expected InstalledLibrary, not InstalledLibrary[]'); + } + if (installedLibs.indexOf(lib) === -1) { + installedLibs.push(lib); + } + return Promise.resolve(); + }); + + repo.delete.mockImplementation((lib) => { + const index = installedLibs.indexOf(lib as InstalledLibrary); + if (index > -1) { + installedLibs.splice(index, 1); + } else { + throw new Error('Library not found'); + } + return Promise.resolve(); + }); + + const savedFiles: [string, string][] = []; + + s3ClientAdapter.create.mockImplementation(async (filepath, dto) => { + const content = await readStream(dto.data); + savedFiles.push([filepath, content]); + return Promise.resolve({} as ServiceOutputTypes); + }); + + s3ClientAdapter.head.mockImplementation((filepath) => { + for (const file of savedFiles) { + if (file[0] === filepath) { + return Promise.resolve({ contentLength: file[1].length } as unknown as HeadObjectCommandOutput); + } + } + throw new Error(`S3 object under ${filepath} not found`); + }); + + s3ClientAdapter.get.mockImplementation((filepath) => { + for (const file of savedFiles) { + if (file[0] === filepath) { + return Promise.resolve({ + name: file[1], + contentLength: file[1].length, + data: Readable.from(Buffer.from(file[1])), + }); + } + } + throw new Error(`S3 object under ${filepath} not found`); + }); + }); + + const createTestData = () => { + const metadataToName = ({ machineName, majorVersion, minorVersion }: ILibraryMetadata): ILibraryName => { + return { + machineName, + majorVersion, + minorVersion, + }; + }; + const testingLibMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'testing', + majorVersion: 1, + minorVersion: 2, + }; + const testingLib = new InstalledLibrary(testingLibMetadata); + testingLib.files.push( + new FileMetadata('file1', new Date(), 2), + new FileMetadata('file2', new Date(), 4), + new FileMetadata('file3', new Date(), 6) + ); + + const addonLibMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'addon', + majorVersion: 1, + minorVersion: 2, + }; + const addonLib = new InstalledLibrary(addonLibMetadata); + addonLib.addTo = { player: { machineNames: [testingLib.machineName] } }; + + const circularALibMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'circular_a', + majorVersion: 1, + minorVersion: 2, + }; + const circularA = new InstalledLibrary(circularALibMetadata); + const circularBLibMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 3, + machineName: 'circular_b', + majorVersion: 1, + minorVersion: 2, + }; + const circularB = new InstalledLibrary(circularBLibMetadata); + circularA.preloadedDependencies = [metadataToName(circularB)]; + circularB.editorDependencies = [metadataToName(circularA)]; + + const fakeLibraryName: ILibraryName = { machineName: 'fake', majorVersion: 2, minorVersion: 3 }; + + const testingLibDependentAMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 6, + machineName: 'first_dependent', + majorVersion: 2, + minorVersion: 5, + }; + const testingLibDependentA = new InstalledLibrary(testingLibDependentAMetadata); + testingLibDependentA.dynamicDependencies = [metadataToName(testingLib)]; + + const testingLibDependentBMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 6, + machineName: 'second_dependent', + majorVersion: 2, + minorVersion: 5, + }; + const testingLibDependentB = new InstalledLibrary(testingLibDependentBMetadata); + testingLibDependentB.preloadedDependencies = [metadataToName(testingLib)]; + + const libWithNonExistingDependencyMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: 6, + machineName: 'fake_dependency', + majorVersion: 2, + minorVersion: 5, + }; + const libWithNonExistingDependency = new InstalledLibrary(libWithNonExistingDependencyMetadata); + libWithNonExistingDependency.editorDependencies = [fakeLibraryName]; + + return { + libraries: [ + testingLib, + addonLib, + circularA, + circularB, + testingLibDependentA, + testingLibDependentB, + libWithNonExistingDependency, + ], + names: { + testingLib, + addonLib, + fakeLibraryName, + }, + }; + }; + + it('should be defined', () => { + expect(storage).toBeDefined(); + }); + + describe('when managing library metadata', () => { + const setup = async (addLibrary = true) => { + const { + names: { testingLib }, + } = createTestData(); + + if (addLibrary) { + await storage.addLibrary(testingLib, false); + } + + return { testingLib }; + }; + + describe('when adding library', () => { + it('should succeed', async () => { + await setup(); + + expect(repo.createLibrary).toHaveBeenCalled(); + }); + + it('should fail to override existing library', async () => { + const { testingLib } = await setup(); + + repo.findByNameAndExactVersion.mockResolvedValue(testingLib); + + const addLib = storage.addLibrary(testingLib, false); + await expect(addLib).rejects.toThrowError("Can't add library because it already exists"); + }); + }); + + describe('when getting metadata', () => { + it('should succeed if library exists', async () => { + const { testingLib } = await setup(); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(testingLib); + + const returnedLibrary = await storage.getLibrary(testingLib); + expect(returnedLibrary).toEqual(expect.objectContaining(testingLib)); + }); + + it("should fail if library doesn't exist", async () => { + const { testingLib } = await setup(false); + + repo.findOneByNameAndVersionOrFail.mockImplementation(() => { + throw new Error('Library does not exist'); + }); + + const getLibrary = storage.getLibrary(testingLib); + await expect(getLibrary).rejects.toThrowError(); + }); + }); + + describe('when checking installed status', () => { + it('should return true if library is installed', async () => { + const { testingLib } = await setup(); + + repo.findNewestByNameAndVersion.mockResolvedValue(testingLib); + + const installed = await storage.isInstalled(testingLib); + expect(installed).toBe(true); + }); + + it("should return false if library isn't installed", async () => { + const { testingLib } = await setup(false); + + repo.findNewestByNameAndVersion.mockResolvedValue(null); + + const installed = await storage.isInstalled(testingLib); + expect(installed).toBe(false); + }); + }); + + describe('when updating metadata', () => { + it('should update metadata', async () => { + const { testingLib } = await setup(); + + const libFromDatabaseMetadata: ILibraryMetadata = { + runnable: false, + title: '', + patchVersion: testingLib.patchVersion, + machineName: testingLib.machineName, + majorVersion: testingLib.majorVersion, + minorVersion: testingLib.minorVersion, + }; + const libFromDatabase = new InstalledLibrary(libFromDatabaseMetadata); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(libFromDatabase); + + testingLib.author = 'Test Author'; + const updatedLibrary = await storage.updateLibrary(testingLib); + const retrievedLibrary = await storage.getLibrary(testingLib); + expect(retrievedLibrary).toEqual(updatedLibrary); + expect(repo.save).toHaveBeenCalled(); + }); + + it("should fail if library doesn't exist", async () => { + const { testingLib } = await setup(false); + + repo.findOneByNameAndVersionOrFail.mockImplementation(() => { + throw new Error('Library is not installed'); + }); + + const updateLibrary = storage.updateLibrary(testingLib); + await expect(updateLibrary).rejects.toThrowError('Library is not installed'); + }); + }); + + describe('when updating additional metadata', () => { + it('should return true if data has changed', async () => { + const { testingLib } = await setup(); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(testingLib); + + const updated = await storage.updateAdditionalMetadata(testingLib, { restricted: true }); + expect(updated).toBe(true); + }); + + it("should return false if data hasn't changed", async () => { + const { testingLib } = await setup(); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(testingLib); + + const updated = await storage.updateAdditionalMetadata(testingLib, { restricted: false }); + expect(updated).toBe(false); + }); + + it('should fail if data could not be updated', async () => { + const { testingLib } = await setup(); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(testingLib); + repo.save.mockImplementation(() => { + throw new Error('Library could not be saved'); + }); + + const updateMetadata = storage.updateAdditionalMetadata(testingLib, { restricted: true }); + await expect(updateMetadata).rejects.toThrowError(); + }); + }); + + describe('when deleting library', () => { + it('should succeed if library exists', async () => { + const { testingLib } = await setup(); + + repo.findOneByNameAndVersionOrFail.mockResolvedValue(testingLib); + repo.delete.mockImplementation(() => { + repo.findOneByNameAndVersionOrFail.mockImplementation(() => { + throw new Error('Library is not installed'); + }); + return Promise.resolve(); + }); + + // @ts-expect-error test case + s3ClientAdapter.list.mockResolvedValueOnce({ files: [] }); + + await storage.deleteLibrary(testingLib); + await expect(storage.getLibrary(testingLib)).rejects.toThrow(); + expect(s3ClientAdapter.delete).toHaveBeenCalled(); + }); + + it("should fail if library doesn't exists", async () => { + const { testingLib } = await setup(false); + + repo.findOneByNameAndVersionOrFail.mockImplementation(() => { + throw new Error('Library is not installed'); + }); + + const deleteLibrary = storage.deleteLibrary(testingLib); + await expect(deleteLibrary).rejects.toThrowError(); + }); + }); + }); + + describe('getLibraryFile', () => { + describe('when getting library.json file', () => { + const setup = async (addLibrary = true) => { + const { + names: { testingLib }, + } = createTestData(); + + if (addLibrary) { + await storage.addLibrary(testingLib, false); + } + const ubername = 'testing-1.2'; + const file = 'library.json'; + + return { testingLib, file, ubername }; + }; + + it('should return library.json file', async () => { + const { testingLib, file, ubername } = await setup(); + repo.findOneByNameAndVersionOrFail.mockResolvedValueOnce(testingLib); + + const result = await storage.getLibraryFile(ubername, file); + + expect(result).toBeDefined(); + expect(result.mimetype).toBeDefined(); + expect(result.mimetype).toEqual('application/json'); + }); + }); + }); + + describe('When getting library dependencies', () => { + const setup = async () => { + const { libraries, names } = createTestData(); + + for await (const library of libraries) { + await storage.addLibrary(library, false); + } + + return names; + }; + + it('should find addon libraries', async () => { + const { addonLib } = await setup(); + + const addons = await storage.listAddons(); + expect(addons).toEqual([addonLib]); + }); + + it('should count dependencies', async () => { + await setup(); + + const dependencies = await storage.getAllDependentsCount(); + expect(dependencies).toEqual({ 'circular_a-1.2': 1, 'testing-1.2': 2, 'fake-2.3': 1 }); + }); + + it('should count dependents for single library', async () => { + const { testingLib } = await setup(); + + const count = await storage.getDependentsCount(testingLib); + expect(count).toBe(2); + }); + + it('should count dependencies for library without dependents', async () => { + const { addonLib } = await setup(); + + const count = await storage.getDependentsCount(addonLib); + expect(count).toBe(0); + }); + }); + + describe('when listing libraries', () => { + const setup = async () => { + const { + libraries, + names: { testingLib }, + } = createTestData(); + + for await (const library of libraries) { + await storage.addLibrary(library, false); + } + + return { libraries, testingLib }; + }; + + it('should return all libraries when no filter is used', async () => { + const { libraries } = await setup(); + + const allLibraries = await storage.getInstalledLibraryNames(); + expect(allLibraries.length).toBe(libraries.length); + }); + + it('should return all libraries with machinename', async () => { + const { testingLib } = await setup(); + + const allLibraries = await storage.getInstalledLibraryNames(testingLib.machineName); + expect(allLibraries.length).toBe(1); + }); + }); + + describe('when managing files', () => { + const setup = async (addLib = true, addFiles = true) => { + const { + names: { testingLib }, + } = createTestData(); + + const testFile = { + name: 'test/abc.json', + content: JSON.stringify({ property: 'value' }), + }; + + if (addLib) { + await storage.addLibrary(testingLib, false); + } + + if (addFiles) { + await storage.addFile(testingLib, testFile.name, Readable.from(Buffer.from(testFile.content))); + } + + return { testingLib, testFile }; + }; + + describe('when adding files', () => { + it('should work', async () => { + await setup(); + }); + + it('should fail on illegal filename', async () => { + const { testingLib } = await setup(); + + const filenames = ['../abc.json', '/test/abc.json']; + + await Promise.all( + filenames.map((filename) => { + const addFile = () => storage.addFile(testingLib, filename, Readable.from(Buffer.from(''))); + return expect(addFile).rejects.toThrow('illegal-filename'); + }) + ); + }); + + describe('when s3 upload error', () => { + it('should throw H5P Error', async () => { + const { testingLib } = await setup(); + const filename = 'test/abc.json'; + + s3ClientAdapter.create.mockImplementationOnce(() => { + throw Error('S3 Exception'); + }); + + const addFile = () => storage.addFile(testingLib, filename, Readable.from(Buffer.from(''))); + return expect(addFile).rejects.toThrow( + new H5pError(`mongo-s3-library-storage:s3-upload-error (ubername: testing-1.2, filename: test/abc.json)`) + ); + }); + }); + }); + + it('should list all files', async () => { + const { testingLib, testFile } = await setup(); + + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: [testFile.name] }); + + const files = await storage.listFiles(testingLib); + expect(files).toContainEqual(expect.stringContaining(testFile.name)); + }); + + describe('when checking if file exists', () => { + it('should return true if it exists', async () => { + const { testingLib, testFile } = await setup(); + + const exists = await storage.fileExists(testingLib, testFile.name); + expect(exists).toBe(true); + }); + + it("should return false if it doesn't exist", async () => { + const { testingLib, testFile } = await setup(true, false); + + const exists = await storage.fileExists(testingLib, testFile.name); + expect(exists).toBe(false); + }); + }); + + describe('when clearing files', () => { + it('should remove all files', async () => { + const { testingLib, testFile } = await setup(); + + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: [testFile.name] }); + + await storage.clearFiles(testingLib); + + expect(s3ClientAdapter.delete).toHaveBeenCalledWith([expect.stringContaining(testFile.name)]); + }); + + it("should fail if library doesn't exist", async () => { + const { testingLib } = await setup(false, false); + + const clearFiles = () => storage.clearFiles(testingLib); + await expect(clearFiles).rejects.toThrow('mongo-s3-library-storage:clear-library-not-found'); + }); + }); + + describe('when retrieving files', () => { + it('should return parsed json', async () => { + const { testingLib, testFile } = await setup(); + + const json = await storage.getFileAsJson(testingLib, testFile.name); + expect(json).toEqual(JSON.parse(testFile.content)); + }); + + it('should return file as string', async () => { + const { testingLib, testFile } = await setup(); + + const fileContent = await storage.getFileAsString(testingLib, testFile.name); + expect(fileContent).toEqual(testFile.content); + }); + + it('should return file as stream', async () => { + const { testingLib, testFile } = await setup(); + + const fileStream = await storage.getFileStream(testingLib, testFile.name); + + const streamContents = await new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chunks: any[] = []; + fileStream.on('data', (chunk) => chunks.push(chunk)); + fileStream.on('error', reject); + fileStream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + }); + + expect(streamContents).toEqual(testFile.content); + }); + }); + describe('when getting file stats', () => { + it('should return file stats', async () => { + const { testingLib, testFile } = await setup(); + + const mockStats = { + LastModified: new Date(), + ContentLength: 15, + }; + + // @ts-expect-error partial mock + s3ClientAdapter.head.mockResolvedValueOnce(mockStats); + + const stats = await storage.getFileStats(testingLib, testFile.name); + + expect(stats).toMatchObject({ + size: mockStats.ContentLength, + birthtime: mockStats.LastModified, + }); + }); + + it('should fail if filename is invalid', async () => { + const { testingLib } = await setup(true, false); + + const getStats = storage.getFileStats(testingLib, '../invalid'); + await expect(getStats).rejects.toThrowError('illegal-filename'); + }); + + it('should throw NotFoundException if the file has no content-length or birthtime', async () => { + const { testingLib, testFile } = await setup(); + + s3ClientAdapter.head + // @ts-expect-error partial mock + .mockResolvedValueOnce({ + LastModified: new Date(), + }) + // @ts-expect-error partial mock + .mockResolvedValueOnce({ + ContentLength: 10, + }); + + const undefinedLength = storage.getFileStats(testingLib, testFile.name); + await expect(undefinedLength).rejects.toThrowError(NotFoundException); + + const undefinedBirthtime = storage.getFileStats(testingLib, testFile.name); + await expect(undefinedBirthtime).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('when getting languages', () => { + const setup = async () => { + const { + names: { testingLib }, + } = createTestData(); + + await storage.addLibrary(testingLib, false); + + const languageFiles = ['en.json', 'de.json']; + const languages = ['en', 'de']; + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: languageFiles }); + + return { testingLib, languages }; + }; + + it('should return a list of languages', async () => { + const { testingLib, languages } = await setup(); + + const supportedLanguages = await storage.getLanguages(testingLib); + expect(supportedLanguages).toEqual(expect.arrayContaining(languages)); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/service/libraryStorage.service.ts b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.ts new file mode 100644 index 00000000000..6839a9d2a27 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.ts @@ -0,0 +1,452 @@ +import { + H5pError, + LibraryName, + streamToString, + type IAdditionalLibraryMetadata, + type IFileStats, + type IInstalledLibrary, + type ILibraryMetadata, + type ILibraryName, + type ILibraryStorage, +} from '@lumieducation/h5p-server'; +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import mime from 'mime'; +import path from 'node:path/posix'; +import { Readable } from 'stream'; +import { H5pFileDto } from '../controller/dto'; +import { InstalledLibrary } from '../entity/library.entity'; +import { H5P_LIBRARIES_S3_CONNECTION } from '../h5p-editor.config'; +import { LibraryRepo } from '../repo/library.repo'; + +@Injectable() +export class LibraryStorage implements ILibraryStorage { + /** + * @param + */ + constructor( + private readonly libraryRepo: LibraryRepo, + @Inject(H5P_LIBRARIES_S3_CONNECTION) private readonly s3Client: S3ClientAdapter + ) {} + + /** + * Checks if the filename is absolute or traverses outside the directory. + * Throws an error if the filename is illegal. + * @param filename the requested file + */ + private checkFilename(filename: string): void { + const hasPathTraversal = /\.\.\//.test(filename); + const isAbsolutePath = filename.startsWith('/'); + + if (hasPathTraversal || isAbsolutePath) { + throw new H5pError('illegal-filename', { filename }, 400); + } + } + + private getS3Key(library: ILibraryName, filename: string) { + const uberName = LibraryName.toUberName(library); + const s3Key = `h5p-libraries/${uberName}/${filename}`; + + return s3Key; + } + + /** + * Adds a file to a library. Library metadata must be installed using `installLibrary` first. + * @param library + * @param filename + * @param dataStream + * @returns true if successful + */ + public async addFile(libraryName: ILibraryName, filename: string, dataStream: Readable): Promise { + this.checkFilename(filename); + + const s3Key = this.getS3Key(libraryName, filename); + + try { + await this.s3Client.create( + s3Key, + new H5pFileDto({ + name: s3Key, + mimeType: 'application/octet-stream', + data: dataStream, + }) + ); + } catch (error) { + throw new H5pError( + `mongo-s3-library-storage:s3-upload-error`, + { ubername: LibraryName.toUberName(libraryName), filename }, + 500 + ); + } + + return true; + } + + /** + * Adds the metadata of the library + * @param libraryMetadata + * @param restricted + * @returns The newly created library object + */ + public async addLibrary(libMeta: ILibraryMetadata, restricted: boolean): Promise { + const existingLibrary = await this.libraryRepo.findByNameAndExactVersion( + libMeta.machineName, + libMeta.majorVersion, + libMeta.minorVersion, + libMeta.patchVersion + ); + + if (existingLibrary !== null) { + throw new ConflictException("Can't add library because it already exists"); + } + + const library = new InstalledLibrary(libMeta, restricted, undefined); + + await this.libraryRepo.createLibrary(library); + + return library; + } + + /** + * Removes all files of a library, but keeps the metadata + * @param library + */ + public async clearFiles(libraryName: ILibraryName): Promise { + const isInstalled = await this.isInstalled(libraryName); + + if (!isInstalled) { + throw new H5pError('mongo-s3-library-storage:clear-library-not-found', { + ubername: LibraryName.toUberName(libraryName), + }); + } + + const filesToDelete = await this.listFiles(libraryName, false); + + await this.s3Client.delete(filesToDelete.map((file) => this.getS3Key(libraryName, file))); + } + + /** + * Deletes metadata and all files of the library + * @param library + */ + public async deleteLibrary(libraryName: ILibraryName): Promise { + const isInstalled = await this.isInstalled(libraryName); + + if (!isInstalled) { + throw new H5pError('mongo-s3-library-storage:library-not-found'); + } + + await this.clearFiles(libraryName); + + const library = await this.libraryRepo.findOneByNameAndVersionOrFail( + libraryName.machineName, + libraryName.majorVersion, + libraryName.minorVersion + ); + + await this.libraryRepo.delete(library); + } + + /** + * Checks if the file exists in the library + * @param library + * @param filename + * @returns true if the file exists, false otherwise + */ + public async fileExists(libraryName: ILibraryName, filename: string): Promise { + this.checkFilename(filename); + + try { + await this.s3Client.head(this.getS3Key(libraryName, filename)); + return true; + } catch (error) { + return false; + } + } + + /** + * Counts how often libraries are listed in the dependencies of other libraries and returns a list of the number. + * @returns an object with ubernames as key. + */ + public async getAllDependentsCount(): Promise<{ [ubername: string]: number }> { + const libraries = await this.libraryRepo.getAll(); + const libraryMap = new Map(libraries.map((library) => [LibraryName.toUberName(library), library])); + + // Remove circular dependencies + for (const library of libraries) { + for (const dependency of library.editorDependencies ?? []) { + const ubername = LibraryName.toUberName(dependency); + + const dependencyMetadata = libraryMap.get(ubername); + + if (dependencyMetadata?.preloadedDependencies) { + const index = dependencyMetadata.preloadedDependencies.findIndex((libName) => + LibraryName.equal(libName, library) + ); + + if (index >= 0) { + dependencyMetadata.preloadedDependencies.splice(index, 1); + } + } + } + } + + // Count dependencies + const dependencies: { [ubername: string]: number } = {}; + for (const library of libraries) { + const { preloadedDependencies = [], editorDependencies = [], dynamicDependencies = [] } = library; + + for (const dependency of preloadedDependencies.concat(editorDependencies, dynamicDependencies)) { + const ubername = LibraryName.toUberName(dependency); + dependencies[ubername] = (dependencies[ubername] ?? 0) + 1; + } + } + + return dependencies; + } + + /** + * Counts how many dependents the library has. + * @param library + * @returns the count + */ + public async getDependentsCount(library: ILibraryName): Promise { + const allDependencies = await this.getAllDependentsCount(); + return allDependencies[LibraryName.toUberName(library)] ?? 0; + } + + /** + * Returns the file as a JSON-parsed object + * @param library + * @param file + */ + public async getFileAsJson(library: ILibraryName, file: string): Promise { + const content = await this.getFileAsString(library, file); + return JSON.parse(content) as unknown; + } + + /** + * Returns the file as a utf-8 string + * @param library + * @param file + */ + public async getFileAsString(library: ILibraryName, file: string): Promise { + const stream = await this.getFileStream(library, file); + const data = await streamToString(stream); + return data; + } + + /** + * Returns information about a library file + * @param library + * @param file + */ + public async getFileStats(libraryName: ILibraryName, file: string): Promise { + this.checkFilename(file); + + const s3Key = this.getS3Key(libraryName, file); + const head = await this.s3Client.head(s3Key); + + if (head.LastModified === undefined || head.ContentLength === undefined) { + throw new NotFoundException(); + } + + return { + birthtime: head.LastModified, + size: head.ContentLength, + }; + } + + /** + * Returns a readable stream of the file's contents. + * @param library + * @param file + */ + public async getFileStream(library: ILibraryName, file: string): Promise { + const ubername = LibraryName.toUberName(library); + + const response = await this.getLibraryFile(ubername, file); + + return response.stream; + } + + /** + * Lists all installed libraries or the installed libraries that have the machine name + * @param machineName (optional) only return libraries that have this machine name + */ + public async getInstalledLibraryNames(machineName?: string): Promise { + if (machineName) { + return this.libraryRepo.findByName(machineName); + } + return this.libraryRepo.getAll(); + } + + /** + * Lists all languages supported by a library + * @param library + */ + public async getLanguages(libraryName: ILibraryName): Promise { + const prefix = this.getS3Key(libraryName, 'language'); + + const { files } = await this.s3Client.list({ path: prefix }); + + const jsonFiles = files.filter((file) => path.extname(file) === '.json'); + const languages = jsonFiles.map((file) => path.basename(file, '.json')); + + return languages; + } + + /** + * Returns the library metadata + * @param library + */ + public async getLibrary(library: ILibraryName): Promise { + return this.libraryRepo.findOneByNameAndVersionOrFail( + library.machineName, + library.majorVersion, + library.minorVersion + ); + } + + /** + * Checks if a library is installed + * @param library + */ + public async isInstalled(libraryName: ILibraryName): Promise { + const library = await this.libraryRepo.findNewestByNameAndVersion( + libraryName.machineName, + libraryName.majorVersion, + libraryName.minorVersion + ); + return library !== null; + } + + /** + * Lists all addons that are installed in the system. + */ + public async listAddons(): Promise { + const installedLibraryNames = await this.getInstalledLibraryNames(); + const installedLibraries = await Promise.all(installedLibraryNames.map((addonName) => this.getLibrary(addonName))); + const addons = installedLibraries.filter((library) => library.addTo !== undefined); + + return addons; + } + + /** + * Returns all files that are a part of the library + * @param library + * @param withMetadata wether to include metadata file + * @returns an array of filenames + */ + public async listFiles(libraryName: ILibraryName, withMetadata = true): Promise { + const prefix = this.getS3Key(libraryName, 'language'); + + const { files } = await this.s3Client.list({ path: prefix }); + + if (withMetadata) { + return files.concat('library.json'); + } + + return files; + } + + /** + * Updates the additional metadata properties that are added to the stored libraries. + * @param library + * @param additionalMetadata + */ + public async updateAdditionalMetadata( + libraryName: ILibraryName, + additionalMetadata: Partial + ): Promise { + const library = await this.getLibrary(libraryName); + + let dirty = false; + for (const [property, value] of Object.entries(additionalMetadata)) { + if (value !== library[property]) { + library[property] = value; + dirty = true; + } + } + + // Don't write file if nothing has changed + if (!dirty) { + return false; + } + + await this.libraryRepo.save(library); + + return true; + } + + /** + * Updates the library metadata + * @param libraryMetadata + */ + async updateLibrary(library: ILibraryMetadata): Promise { + const existingLibrary = await this.libraryRepo.findOneByNameAndVersionOrFail( + library.machineName, + library.majorVersion, + library.minorVersion + ); + let dirty = false; + for (const [property, value] of Object.entries(library)) { + if (property !== '_id' && value !== existingLibrary[property]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + existingLibrary[property] = value; + dirty = true; + } + } + if (dirty) { + await this.libraryRepo.save(existingLibrary); + } + + return existingLibrary; + } + + private async getMetadata(library: ILibraryName): Promise { + const result = await this.libraryRepo.findOneByNameAndVersionOrFail( + library.machineName, + library.majorVersion, + library.minorVersion + ); + + return result; + } + + /** + * Returns a file from a library + * @param ubername Library ubername + * @param file file + * @returns a readable stream, mimetype and size + */ + public async getLibraryFile(ubername: string, file: string) { + const libraryName = LibraryName.fromUberName(ubername); + + this.checkFilename(file); + + let result: { stream: Readable | never; mimetype: string; size: number | undefined } | null = null; + + if (file === 'library.json') { + const metadata = await this.getMetadata(libraryName); + const stringifiedMetadata = JSON.stringify(metadata); + const readable = Readable.from(stringifiedMetadata); + + result = { + stream: readable, + mimetype: 'application/json', + size: stringifiedMetadata.length, + }; + } else { + const response = await this.s3Client.get(this.getS3Key(libraryName, file)); + const mimetype = mime.lookup(file, 'application/octet-stream'); + + result = { + stream: response.data, + mimetype, + size: response.contentLength, + }; + } + return result; + } +} diff --git a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts new file mode 100644 index 00000000000..bcbdfa9945c --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts @@ -0,0 +1,309 @@ +import { ServiceOutputTypes } from '@aws-sdk/client-s3'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { IUser } from '@lumieducation/h5p-server'; +import { Test, TestingModule } from '@nestjs/testing'; +import { File, S3ClientAdapter } from '@shared/infra/s3-client'; +import { ReadStream } from 'fs'; +import { Readable } from 'node:stream'; +import { GetH5pFileResponse } from '../controller/dto'; +import { H5pEditorTempFile } from '../entity/h5p-editor-tempfile.entity'; +import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; +import { TemporaryFileRepo } from '../repo/temporary-file.repo'; +import { TemporaryFileStorage } from './temporary-file-storage.service'; + +const today = new Date(); +const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + +describe('TemporaryFileStorage', () => { + let module: TestingModule; + let storage: TemporaryFileStorage; + let s3clientAdapter: DeepMocked; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TemporaryFileStorage, + { + provide: TemporaryFileRepo, + useValue: createMock(), + }, + { provide: H5P_CONTENT_S3_CONNECTION, useValue: createMock() }, + ], + }).compile(); + storage = module.get(TemporaryFileStorage); + s3clientAdapter = module.get(H5P_CONTENT_S3_CONNECTION); + repo = module.get(TemporaryFileRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const fileContent = (userId: string, filename: string) => `Test content of ${userId}'s ${filename}`; + + const setup = () => { + const user1: Required = { + email: 'user1@example.org', + id: '12345-12345', + name: 'Marla Mathe', + type: 'local', + canCreateRestricted: false, + canInstallRecommended: false, + canUpdateAndInstallLibraries: false, + }; + const filename1 = 'abc/def.txt'; + const file1 = new H5pEditorTempFile({ + filename: filename1, + ownedByUserId: user1.id, + expiresAt: tomorrow, + birthtime: new Date(), + size: fileContent(user1.id, filename1).length, + }); + + const user2: Required = { + email: 'user2@example.org', + id: '54321-54321', + name: 'Mirjam Mathe', + type: 'local', + canCreateRestricted: false, + canInstallRecommended: false, + canUpdateAndInstallLibraries: false, + }; + const filename2 = 'uvw/xyz.txt'; + const file2 = new H5pEditorTempFile({ + filename: filename2, + ownedByUserId: user2.id, + expiresAt: tomorrow, + birthtime: new Date(), + size: fileContent(user2.id, filename2).length, + }); + + return { + user1, + user2, + file1, + file2, + }; + }; + + it('service should be defined', () => { + expect(storage).toBeDefined(); + }); + + describe('deleteFile is called', () => { + describe('WHEN file exists', () => { + it('should delete file', async () => { + const { user1, file1 } = setup(); + const res = [`h5p-tempfiles/${user1.id}/${file1.filename}`]; + repo.findByUserAndFilename.mockResolvedValueOnce(file1); + + await storage.deleteFile(file1.filename, user1.id); + + expect(repo.delete).toHaveBeenCalled(); + expect(s3clientAdapter.delete).toHaveBeenCalledTimes(1); + expect(s3clientAdapter.delete).toHaveBeenCalledWith(res); + }); + }); + describe('WHEN file does not exist', () => { + it('should throw error', async () => { + const { user1, file1 } = setup(); + repo.findByUserAndFilename.mockImplementation(() => { + throw new Error('Not found'); + }); + + await expect(async () => { + await storage.deleteFile(file1.filename, user1.id); + }).rejects.toThrow(); + + expect(repo.delete).not.toHaveBeenCalled(); + expect(s3clientAdapter.delete).not.toHaveBeenCalled(); + }); + }); + }); + + describe('fileExists is called', () => { + describe('WHEN file exists', () => { + it('should return true', async () => { + const { user1, file1 } = setup(); + repo.findByUserAndFilename.mockResolvedValueOnce(file1); + + const result = await storage.fileExists(file1.filename, user1); + + expect(result).toBe(true); + }); + }); + describe('WHEN file does not exist', () => { + it('should return false', async () => { + const { user1 } = setup(); + repo.findAllByUserAndFilename.mockResolvedValue([]); + + const exists = await storage.fileExists('abc/nonexistingfile.txt', user1); + + expect(exists).toBe(false); + }); + }); + }); + + describe('getFileStats is called', () => { + describe('WHEN file exists', () => { + it('should return file stats', async () => { + const { user1, file1 } = setup(); + repo.findByUserAndFilename.mockResolvedValueOnce(file1); + + const filestats = await storage.getFileStats(file1.filename, user1); + + expect(filestats.size).toBe(file1.size); + expect(filestats.birthtime).toBe(file1.birthtime); + }); + }); + describe('WHEN file does not exist', () => { + it('should throw error', async () => { + const { user1 } = setup(); + repo.findByUserAndFilename.mockImplementation(() => { + throw new Error('Not found'); + }); + + const fileStatsPromise = storage.getFileStats('abc/nonexistingfile.txt', user1); + + await expect(fileStatsPromise).rejects.toThrow(); + }); + }); + describe('WHEN filename is invalid', () => { + it('should throw error', async () => { + const { user1 } = setup(); + const fileStatsPromise = storage.getFileStats('/../&$!.txt', user1); + await expect(fileStatsPromise).rejects.toThrow(); + }); + }); + }); + + describe('getFileStream is called', () => { + describe('WHEN file exists and no range is given', () => { + it('should return readable file stream', async () => { + const { user1, file1 } = setup(); + const actualContent = fileContent(user1.id, file1.filename); + const response: Required = { + data: Readable.from(actualContent), + etag: '', + contentType: '', + contentLength: 0, + contentRange: '', + name: '', + }; + + repo.findByUserAndFilename.mockResolvedValueOnce(file1); + s3clientAdapter.get.mockResolvedValueOnce(response); + + const stream = await storage.getFileStream(file1.filename, user1); + + let content = Buffer.alloc(0); + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + content += chunk; + }); + stream.on('error', reject); + stream.on('end', resolve); + }); + + expect(content).not.toBe(null); + expect(content.toString()).toEqual(actualContent); + }); + }); + describe('WHEN file does not exist', () => { + it('should throw error', async () => { + const { user1 } = setup(); + repo.findByUserAndFilename.mockImplementation(() => { + throw new Error('Not found'); + }); + + const fileStreamPromise = storage.getFileStream('abc/nonexistingfile.txt', user1); + + await expect(fileStreamPromise).rejects.toThrow(); + }); + }); + }); + + describe('listFiles is called', () => { + describe('WHEN existing user is given', () => { + it('should return only users file', async () => { + const { user1, file1 } = setup(); + repo.findByUser.mockResolvedValueOnce([file1]); + + const files = await storage.listFiles(user1); + + expect(files.length).toBe(1); + expect(files[0].ownedByUserId).toBe(user1.id); + expect(files[0].filename).toBe(file1.filename); + }); + }); + describe('WHEN no user is given', () => { + it('should return all expired files)', async () => { + const { user1, user2, file1, file2 } = setup(); + repo.findExpired.mockResolvedValueOnce([file1, file2]); + + const files = await storage.listFiles(); + + expect(files.length).toBe(2); + expect(files[0].ownedByUserId).toBe(user1.id); + expect(files[1].ownedByUserId).toBe(user2.id); + }); + }); + }); + describe('saveFile is called', () => { + describe('WHEN file exists', () => { + it('should overwrite file', async () => { + const { user1, file1 } = setup(); + const newData = 'This is new fake H5P content.'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const readStream = Readable.from(newData) as ReadStream; + repo.findByUserAndFilename.mockResolvedValueOnce(file1); + let savedData = Buffer.alloc(0); + s3clientAdapter.create.mockImplementation(async (path: string, file: File) => { + savedData += file.data.read(); + return Promise.resolve({} as ServiceOutputTypes); + }); + + await storage.saveFile(file1.filename, readStream, user1, tomorrow); + + expect(s3clientAdapter.delete).toHaveBeenCalled(); + expect(savedData.toString()).toBe(newData); + }); + }); + + describe('WHEN file does not exist', () => { + it('should create and overwrite new file', async () => { + const { user1 } = setup(); + const filename = 'newfile.txt'; + const newData = 'This is new fake H5P content.'; + const readStream = Readable.from(newData) as ReadStream; + let savedData = Buffer.alloc(0); + s3clientAdapter.create.mockImplementation(async (path: string, file: File) => { + savedData += file.data.read(); + return Promise.resolve({} as ServiceOutputTypes); + }); + + await storage.saveFile(filename, readStream, user1, tomorrow); + + expect(s3clientAdapter.delete).toHaveBeenCalled(); + expect(savedData.toString()).toBe(newData); + }); + }); + + describe('WHEN expirationTime is in the past', () => { + it('should throw error', async () => { + const { user1, file1 } = setup(); + const newData = 'This is new fake H5P content.'; + const readStream = Readable.from(newData) as ReadStream; + + const saveFile = storage.saveFile(file1.filename, readStream, user1, new Date(2023, 0, 1)); + + await expect(saveFile).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts new file mode 100644 index 00000000000..6bfa164e630 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts @@ -0,0 +1,124 @@ +import { ITemporaryFile, ITemporaryFileStorage, IUser } from '@lumieducation/h5p-server'; +import { Inject, Injectable, NotAcceptableException } from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { ReadStream } from 'fs'; +import { Readable } from 'stream'; +import { H5pFileDto } from '../controller/dto/h5p-file.dto'; +import { H5pEditorTempFile } from '../entity/h5p-editor-tempfile.entity'; +import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; +import { TemporaryFileRepo } from '../repo/temporary-file.repo'; + +@Injectable() +export class TemporaryFileStorage implements ITemporaryFileStorage { + constructor( + private readonly repo: TemporaryFileRepo, + @Inject(H5P_CONTENT_S3_CONNECTION) private readonly s3Client: S3ClientAdapter + ) {} + + private checkFilename(filename: string): void { + if (!/^[a-zA-Z0-9/._-]+$/g.test(filename) && filename.includes('..') && filename.startsWith('/')) { + throw new NotAcceptableException(`Filename contains forbidden characters or is empty: '${filename}'`); + } + } + + private getFileInfo(filename: string, userId: string): Promise { + this.checkFilename(filename); + return this.repo.findByUserAndFilename(userId, filename); + } + + public async deleteFile(filename: string, userId: string): Promise { + this.checkFilename(filename); + const meta = await this.repo.findByUserAndFilename(userId, filename); + await this.s3Client.delete([this.getFilePath(userId, filename)]); + await this.repo.delete(meta); + } + + public async fileExists(filename: string, user: IUser): Promise { + this.checkFilename(filename); + const files = await this.repo.findAllByUserAndFilename(user.id, filename); + const exists = files.length !== 0; + return exists; + } + + public async getFileStats(filename: string, user: IUser): Promise { + return this.getFileInfo(filename, user.id); + } + + public async getFileStream( + filename: string, + user: IUser, + rangeStart = 0, + rangeEnd?: number | undefined + ): Promise { + this.checkFilename(filename); + const tempFile = await this.repo.findByUserAndFilename(user.id, filename); + const path = this.getFilePath(user.id, filename); + let rangeEndNew = 0; + if (rangeEnd === undefined) { + rangeEndNew = tempFile.size - 1; + } + const response = await this.s3Client.get(path, `${rangeStart}-${rangeEndNew}`); + + return response.data; + } + + public async listFiles(user?: IUser): Promise { + // method is expected to support listing all files in database + // Lumi uses the variant without a user to search for expired files, so we only return those + + let files: ITemporaryFile[]; + if (user) { + files = await this.repo.findByUser(user.id); + } else { + files = await this.repo.findExpired(); + } + + return files; + } + + public async saveFile( + filename: string, + dataStream: ReadStream, + user: IUser, + expirationTime: Date + ): Promise { + this.checkFilename(filename); + const now = new Date(); + if (expirationTime < now) { + throw new NotAcceptableException('expirationTime must be in the future'); + } + + const path = this.getFilePath(user.id, filename); + let tempFile: H5pEditorTempFile | undefined; + try { + tempFile = await this.repo.findByUserAndFilename(user.id, filename); + await this.s3Client.delete([path]); + } finally { + if (tempFile === undefined) { + tempFile = new H5pEditorTempFile({ + filename, + ownedByUserId: user.id, + expiresAt: expirationTime, + birthtime: new Date(), + size: dataStream.bytesRead, + }); + } else { + tempFile.expiresAt = expirationTime; + tempFile.size = dataStream.bytesRead; + } + } + await this.s3Client.create( + path, + new H5pFileDto({ name: path, mimeType: 'application/octet-stream', data: dataStream }) + ); + await this.repo.save(tempFile); + + return tempFile; + } + + private getFilePath(userId: string, filename: string): string { + const path = `h5p-tempfiles/${userId}/${filename}`; + + return path; + } +} diff --git a/apps/server/src/modules/h5p-editor/types/lumi-types.ts b/apps/server/src/modules/h5p-editor/types/lumi-types.ts new file mode 100644 index 00000000000..ed1aa36a21d --- /dev/null +++ b/apps/server/src/modules/h5p-editor/types/lumi-types.ts @@ -0,0 +1,45 @@ +import { IUser } from '@lumieducation/h5p-server'; +import { EntityId } from '@shared/domain'; +import { H5PContentParentType } from '../entity'; + +export interface H5PContentParentParams { + schoolId: EntityId; + parentType: H5PContentParentType; + parentId: EntityId; +} + +export class LumiUserWithContentData implements IUser { + contentParentType: H5PContentParentType; + + contentParentId: EntityId; + + schoolId: EntityId; + + canCreateRestricted: boolean; + + canInstallRecommended: boolean; + + canUpdateAndInstallLibraries: boolean; + + email: string; + + id: EntityId; + + name: string; + + type: 'local' | string; + + constructor(user: IUser, parentParams: H5PContentParentParams) { + this.contentParentType = parentParams.parentType; + this.contentParentId = parentParams.parentId; + this.schoolId = parentParams.schoolId; + + this.canCreateRestricted = user.canCreateRestricted; + this.canInstallRecommended = user.canInstallRecommended; + this.canUpdateAndInstallLibraries = user.canUpdateAndInstallLibraries; + this.email = user.email; + this.id = user.id; + this.name = user.name; + this.type = user.type; + } +} diff --git a/apps/server/src/modules/h5p-editor/uc/dto/h5p-getLibraryFile.ts b/apps/server/src/modules/h5p-editor/uc/dto/h5p-getLibraryFile.ts new file mode 100644 index 00000000000..2344b9efdbe --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/dto/h5p-getLibraryFile.ts @@ -0,0 +1,8 @@ +import { Readable } from 'stream'; + +export interface GetLibraryFile { + data: Readable; + contentType: string; + contentLength: number; + contentRange?: { start: number; end: number }; +} diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts new file mode 100644 index 00000000000..c42b959b9e8 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts @@ -0,0 +1,227 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PAjaxEndpoint, H5PEditor, H5PPlayer, H5pError } from '@lumieducation/h5p-server'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LanguageType, UserDO } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { H5PErrorMapper } from '../mapper/h5p-error.mapper'; +import { H5PContentRepo } from '../repo'; +import { LibraryStorage } from '../service'; +import { H5PEditorUc } from './h5p.uc'; + +describe('H5P Ajax', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let ajaxEndpoint: DeepMocked; + let userService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + { + provide: H5PEditor, + useValue: createMock(), + }, + { + provide: H5PPlayer, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: H5PAjaxEndpoint, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + ajaxEndpoint = module.get(H5PAjaxEndpoint); + userService = module.get(UserService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when calling GET', () => { + const userMock = { + userId: 'dummyId', + roles: [], + schoolId: 'dummySchool', + accountId: 'dummyAccountId', + isExternalUser: false, + }; + const spy = jest.spyOn(H5PErrorMapper.prototype, 'mapH5pError'); + + it('should call H5PAjaxEndpoint.getAjax and return the result', async () => { + const dummyResponse = { + apiVersion: { major: 1, minor: 1 }, + details: [], + libraries: [], + outdated: false, + recentlyUsed: [], + user: 'DummyUser', + }; + + ajaxEndpoint.getAjax.mockResolvedValueOnce(dummyResponse); + userService.findById.mockResolvedValueOnce({ language: LanguageType.DE } as UserDO); + + const result = await uc.getAjax({ action: 'content-type-cache' }, userMock); + + expect(result).toBe(dummyResponse); + expect(ajaxEndpoint.getAjax).toHaveBeenCalledWith( + 'content-type-cache', + undefined, // MachineName + undefined, // MajorVersion + undefined, // MinorVersion + 'de', + expect.objectContaining({ id: 'dummyId' }) + ); + }); + + it('should invoce h5p-error mapper', async () => { + ajaxEndpoint.getAjax.mockRejectedValueOnce(new Error('Dummy Error')); + await uc.getAjax({ action: 'content-type-cache' }, userMock); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('when calling POST', () => { + const userMock = { + userId: 'dummyId', + roles: [], + schoolId: 'dummySchool', + accountId: 'dummyAccountId', + isExternalUser: false, + }; + const spy = jest.spyOn(H5PErrorMapper.prototype, 'mapH5pError'); + + it('should call H5PAjaxEndpoint.postAjax and return the result', async () => { + const dummyResponse = [ + { + majorVersion: 1, + minorVersion: 2, + metadataSettings: {}, + name: 'Dummy Library', + restricted: false, + runnable: true, + title: 'Dummy Library', + tutorialUrl: '', + uberName: 'dummyLibrary-1.1', + }, + ]; + + ajaxEndpoint.postAjax.mockResolvedValueOnce(dummyResponse); + + const result = await uc.postAjax( + userMock, + { action: 'libraries' }, + { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' } + ); + + expect(result).toBe(dummyResponse); + expect(ajaxEndpoint.postAjax).toHaveBeenCalledWith( + 'libraries', + { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' }, + 'de', + expect.objectContaining({ id: 'dummyId' }), + undefined, + undefined, + undefined, + undefined, + undefined + ); + }); + + it('should call H5PAjaxEndpoint.postAjax with files', async () => { + const dummyResponse = [ + { + majorVersion: 1, + minorVersion: 2, + metadataSettings: {}, + name: 'Dummy Library', + restricted: false, + runnable: true, + title: 'Dummy Library', + tutorialUrl: '', + uberName: 'dummyLibrary-1.1', + }, + ]; + + ajaxEndpoint.postAjax.mockResolvedValueOnce(dummyResponse); + + const result = await uc.postAjax( + userMock, + { action: 'libraries' }, + { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' }, + { + fieldname: 'file', + buffer: Buffer.from(''), + originalname: 'OriginalFile.jpg', + size: 0, + mimetype: 'image/jpg', + } as Express.Multer.File, + { + fieldname: 'h5p', + buffer: Buffer.from(''), + originalname: 'OriginalFile.jpg', + size: 0, + mimetype: 'image/jpg', + } as Express.Multer.File + ); + + const bufferTest = { + data: expect.any(Buffer), + mimetype: 'image/jpg', + name: 'OriginalFile.jpg', + size: 0, + }; + + expect(result).toBe(dummyResponse); + expect(ajaxEndpoint.postAjax).toHaveBeenCalledWith( + 'libraries', + { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' }, + 'de', + expect.objectContaining({ id: 'dummyId' }), + bufferTest, + undefined, + undefined, + bufferTest, + undefined + ); + }); + + it('should invoce h5p-error.mapper', async () => { + ajaxEndpoint.postAjax.mockRejectedValueOnce(new H5pError('dummy-error', { error: 'Dummy Error' }, 400)); + + await uc.postAjax( + userMock, + { action: 'libraries' }, + { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' } + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts new file mode 100644 index 00000000000..174d5c0fd3a --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts @@ -0,0 +1,188 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { H5PContentRepo } from '../repo'; +import { LibraryStorage } from '../service'; +import { H5PAjaxEndpointProvider } from '../provider'; +import { H5PEditorUc } from './h5p.uc'; + +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', + isExternalUser: false, + }; + + return { content, mockCurrentUser }; +}; + +describe('save or create H5P content', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let h5pEditor: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationReferenceService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + H5PAjaxEndpointProvider, + { + provide: H5PEditor, + useValue: createMock(), + }, + { + provide: H5PPlayer, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + h5pEditor = module.get(H5PEditor); + h5pContentRepo = module.get(H5PContentRepo); + authorizationReferenceService = module.get(AuthorizationReferenceService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteH5pContent is called', () => { + describe('WHEN user is authorized and service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + h5pEditor.deleteContent.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser }; + }; + + it('should call authorizationReferenceService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.deleteH5pContent(mockCurrentUser, content.id); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.deleteH5pContent(mockCurrentUser, content.id); + + expect(h5pEditor.deleteContent).toBeCalledWith( + content.id, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); + + it('should return true', async () => { + const { content, mockCurrentUser } = setup(); + + const result = await uc.deleteH5pContent(mockCurrentUser, content.id); + + expect(result).toBe(true); + }); + }); + + describe('WHEN content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); + + return { content, mockCurrentUser }; + }; + + it('should throw NotFoundException', async () => { + const { content, mockCurrentUser } = setup(); + + const deleteH5pContentpromise = uc.deleteH5pContent(mockCurrentUser, content.id); + + await expect(deleteH5pContentpromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { content, mockCurrentUser }; + }; + + it('should throw forbidden error', async () => { + const { content, mockCurrentUser } = setup(); + + const deleteH5pContentpromise = uc.deleteH5pContent(mockCurrentUser, content.id); + + await expect(deleteH5pContentpromise).rejects.toThrow(ForbiddenException); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + const error = new Error('test'); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + h5pEditor.deleteContent.mockRejectedValueOnce(error); + + return { error, content, mockCurrentUser }; + }; + + it('should return error of service', async () => { + const { content, mockCurrentUser } = setup(); + + const deleteH5pContentpromise = uc.deleteH5pContent(mockCurrentUser, content.id); + + await expect(deleteH5pContentpromise).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts new file mode 100644 index 00000000000..ab38282cc56 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts @@ -0,0 +1,592 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PAjaxEndpoint, H5PEditor, IPlayerModel } from '@lumieducation/h5p-server'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { Request } from 'express'; +import { Readable } from 'stream'; +import { H5PContentRepo } from '../repo'; +import { ContentStorage, LibraryStorage } from '../service'; +import { H5PEditorProvider, H5PPlayerProvider } from '../provider'; +import { TemporaryFileStorage } from '../service/temporary-file-storage.service'; +import { H5PEditorUc } from './h5p.uc'; + +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', + isExternalUser: false, + }; + + const mockContentParameters: Awaited> = { + h5p: content.metadata, + library: content.metadata.mainLibrary, + params: { + metadata: content.metadata, + params: content.content, + }, + }; + + const playerResponseMock = expect.objectContaining({ + contentId: content.id, + }) as IPlayerModel; + + return { content, mockCurrentUser, playerResponseMock, mockContentParameters }; +}; + +describe('H5P Files', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let libraryStorage: DeepMocked; + let ajaxEndpointService: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationReferenceService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + H5PEditorProvider, + H5PPlayerProvider, + { + provide: H5PAjaxEndpoint, + useValue: createMock(), + }, + { + provide: ContentStorage, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: TemporaryFileStorage, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + libraryStorage = module.get(LibraryStorage); + ajaxEndpointService = module.get(H5PAjaxEndpoint); + h5pContentRepo = module.get(H5PContentRepo); + authorizationReferenceService = module.get(AuthorizationReferenceService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getContentParameters is called', () => { + describe('WHEN user is authorized and service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + ajaxEndpointService.getContentParameters.mockResolvedValueOnce(mockContentParameters); + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser, mockContentParameters }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getContentParameters(content.id, mockCurrentUser); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getContentParameters(content.id, mockCurrentUser); + + expect(ajaxEndpointService.getContentParameters).toHaveBeenCalledWith( + content.id, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); + + it('should return results of service', async () => { + const { mockCurrentUser, content, mockContentParameters } = setup(); + + const result = await uc.getContentParameters(content.id, mockCurrentUser); + + expect(result).toEqual(mockContentParameters); + }); + }); + + describe('WHEN content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); + + return { content, mockCurrentUser }; + }; + + it('should throw NotFoundException', async () => { + const { mockCurrentUser, content } = setup(); + + const getContentParametersPromise = uc.getContentParameters(content.id, mockCurrentUser); + + await expect(getContentParametersPromise).rejects.toThrow(new NotFoundException()); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { content, mockCurrentUser }; + }; + + it('should throw forbidden error', async () => { + const { mockCurrentUser, content } = setup(); + + const getContentParametersPromise = uc.getContentParameters(content.id, mockCurrentUser); + + await expect(getContentParametersPromise).rejects.toThrow(new ForbiddenException()); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + ajaxEndpointService.getContentParameters.mockRejectedValueOnce(new Error('test')); + + return { content, mockCurrentUser }; + }; + + it('should return NotFoundException', async () => { + const { mockCurrentUser, content } = setup(); + + const getContentParametersPromise = uc.getContentParameters(content.id, mockCurrentUser); + + await expect(getContentParametersPromise).rejects.toThrow(new NotFoundException()); + }); + }); + }); + + describe('getContentFile is called', () => { + describe('WHEN user is authorized and service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const fileResponseMock = createMock>>(); + const requestMock = createMock({ + range: () => undefined, + }); + // Mock partial implementation so that range callback gets called + ajaxEndpointService.getContentFile.mockImplementationOnce((contentId, filename, user, rangeCallback) => { + rangeCallback?.(100); + return Promise.resolve(fileResponseMock); + }); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + const filename = 'test/file.txt'; + + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, filename, requestMock, mockCurrentUser } = setup(); + + await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, mockCurrentUser, filename, requestMock } = setup(); + + await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + expect(ajaxEndpointService.getContentFile).toHaveBeenCalledWith( + content.id, + filename, + expect.objectContaining({ + id: mockCurrentUser.userId, + }), + expect.any(Function) + ); + }); + + it('should return results of service', async () => { + const { mockCurrentUser, fileResponseMock, filename, requestMock, content } = setup(); + + const result = await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + expect(result).toEqual({ + data: fileResponseMock.stream, + contentType: fileResponseMock.mimetype, + contentLength: fileResponseMock.stats.size, + contentRange: fileResponseMock.range, + }); + }); + }); + + describe('WHEN user is authorized and a range is requested', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const range = { start: 0, end: 100 }; + + const requestMock = createMock({ + // @ts-expect-error partial types cause error + range: () => [range], + }); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + ajaxEndpointService.getContentFile.mockImplementationOnce((contentId, filename, user, rangeCallback) => { + const parsedRange = rangeCallback?.(100); + if (!parsedRange) throw new Error('no range'); + return Promise.resolve({ + range: parsedRange, + mimetype: '', + stats: { birthtime: new Date(), size: 100 }, + stream: createMock(), + }); + }); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + const filename = 'test/file.txt'; + + return { range, content, filename, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should return parsed range', async () => { + const { mockCurrentUser, range, content, filename, requestMock } = setup(); + + const result = await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + expect(result.contentRange).toEqual(range); + }); + }); + + describe('WHEN user is authorized but content range is bad', () => { + const setup = (rangeResponse?: { start: number; end: number }[] | -1 | -2) => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock({ + // @ts-expect-error partial types cause error + range() { + return rangeResponse; + }, + }); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + ajaxEndpointService.getContentFile.mockImplementationOnce((contentId, filename, user, rangeCallback) => { + rangeCallback?.(100); + return createMock(); + }); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + const filename = 'test/file.txt'; + + return { content, filename, requestMock, mockCurrentUser, mockContentParameters }; + }; + + describe('WHEN content range is invalid', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock, content } = setup(-2); + + const getContentFilePromise = uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + await expect(getContentFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN content range is unsatisfiable', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock, content } = setup(-1); + + const getContentFilePromise = uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + await expect(getContentFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN content range is multipart', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock, content } = setup([ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + ]); + + const getContentFilePromise = uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + await expect(getContentFilePromise).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('WHEN user is authorized but content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock(); + const fileResponseMock = createMock>>(); + + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); + + const filename = 'test/file.txt'; + + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should return error of service', async () => { + const { mockCurrentUser, filename, requestMock, content } = setup(); + + const getContentFilePromise = uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + await expect(getContentFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN user is authorized but service throws error', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock(); + const fileResponseMock = createMock>>(); + + ajaxEndpointService.getContentFile.mockRejectedValueOnce(new Error('test')); + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + const filename = 'test/file.txt'; + + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should return error of service', async () => { + const { mockCurrentUser, filename, requestMock, content } = setup(); + + const getContentFilePromise = uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + await expect(getContentFilePromise).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('getLibraryFile is called', () => { + describe('WHEN service executes successfully', () => { + const setup = () => { + const fileResponseMock = createMock>>(); + + libraryStorage.getLibraryFile.mockResolvedValueOnce(fileResponseMock); + + const ubername = 'H5P.Test-1.0'; + const filename = 'test/file.txt'; + + return { ubername, filename, fileResponseMock }; + }; + + it('should call service with correct params', async () => { + const { ubername, filename } = setup(); + + await uc.getLibraryFile(ubername, filename); + + expect(libraryStorage.getLibraryFile).toHaveBeenCalledWith(ubername, filename); + }); + + it('should return results of service', async () => { + const { ubername, filename, fileResponseMock } = setup(); + + const result = await uc.getLibraryFile(ubername, filename); + + expect(result).toEqual({ + data: fileResponseMock.stream, + contentType: fileResponseMock.mimetype, + contentLength: fileResponseMock.size, + }); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + libraryStorage.getLibraryFile.mockRejectedValueOnce(new Error('test')); + + const ubername = 'H5P.Test-1.0'; + const filename = 'test/file.txt'; + + return { ubername, filename }; + }; + + it('should return NotFoundException', async () => { + const { ubername, filename } = setup(); + + const getLibraryFilePromise = uc.getLibraryFile(ubername, filename); + + await expect(getLibraryFilePromise).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('getTemporaryFile is called', () => { + describe('WHEN service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock(); + const fileResponseMock = createMock>>(); + + ajaxEndpointService.getTemporaryFile.mockResolvedValueOnce(fileResponseMock); + + const filename = 'test/file.txt'; + + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should call service with correct params', async () => { + const { mockCurrentUser, filename, requestMock } = setup(); + + await uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + expect(ajaxEndpointService.getTemporaryFile).toHaveBeenCalledWith( + filename, + expect.objectContaining({ + id: mockCurrentUser.userId, + }), + expect.any(Function) + ); + }); + + it('should return results of service', async () => { + const { mockCurrentUser, fileResponseMock, filename, requestMock } = setup(); + + const result = await uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + expect(result).toEqual({ + data: fileResponseMock.stream, + contentType: fileResponseMock.mimetype, + contentLength: fileResponseMock.stats.size, + contentRange: fileResponseMock.range, + }); + }); + }); + + describe('WHEN content range is bad', () => { + const setup = (rangeResponse?: { start: number; end: number }[] | -1 | -2) => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock({ + // @ts-expect-error partial types cause error + range() { + return rangeResponse; + }, + }); + + ajaxEndpointService.getTemporaryFile.mockImplementationOnce((filename, user, rangeCallback) => { + rangeCallback?.(100); + return createMock(); + }); + const filename = 'test/file.txt'; + + return { content, filename, requestMock, mockCurrentUser, mockContentParameters }; + }; + + describe('WHEN content range is invalid', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock } = setup(-2); + + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN content range is unsatisfiable', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock } = setup(-1); + + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); + }); + + describe('WHEN content range is multipart', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock } = setup([ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + ]); + + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser, mockContentParameters } = createParams(); + + const requestMock = createMock(); + + ajaxEndpointService.getTemporaryFile.mockRejectedValueOnce(new Error('test')); + + const filename = 'test/file.txt'; + + return { content, filename, requestMock, mockCurrentUser, mockContentParameters }; + }; + + it('should return error of service', async () => { + const { mockCurrentUser, filename, requestMock } = setup(); + + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts new file mode 100644 index 00000000000..4322dd06352 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts @@ -0,0 +1,278 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PEditor, H5PPlayer, IEditorModel } from '@lumieducation/h5p-server'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LanguageType } from '@shared/domain'; +import { UserRepo } from '@shared/repo'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { H5PContentRepo } from '../repo'; +import { LibraryStorage } from '../service'; +import { H5PAjaxEndpointProvider } from '../provider'; +import { H5PEditorUc } from './h5p.uc'; + +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', + isExternalUser: false, + }; + + const editorResponseMock = { scripts: ['test.js'] } as IEditorModel; + const contentResponseMock: Awaited> = { + h5p: content.metadata, + library: content.metadata.mainLibrary, + params: { + metadata: content.metadata, + params: content.content, + }, + }; + + const language = LanguageType.DE; + + return { content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; +}; + +describe('get H5P editor', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let h5pEditor: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationReferenceService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + H5PAjaxEndpointProvider, + { + provide: H5PEditor, + useValue: createMock(), + }, + { + provide: H5PPlayer, + useValue: createMock(), + }, + { + provide: UserRepo, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + h5pEditor = module.get(H5PEditor); + h5pContentRepo = module.get(H5PContentRepo); + authorizationReferenceService = module.get(AuthorizationReferenceService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getEmptyH5pEditor is called', () => { + describe('WHEN service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, language } = createParams(); + + h5pEditor.render.mockResolvedValueOnce(editorResponseMock); + + return { content, mockCurrentUser, editorResponseMock, language }; + }; + + it('should call service with correct params', async () => { + const { mockCurrentUser, language } = setup(); + + await uc.getEmptyH5pEditor(mockCurrentUser, language); + + expect(h5pEditor.render).toHaveBeenCalledWith( + undefined, + language, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); + + it('should return results of service', async () => { + const { mockCurrentUser, language, editorResponseMock } = setup(); + + const result = await uc.getEmptyH5pEditor(mockCurrentUser, language); + + expect(result).toEqual(editorResponseMock); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, language } = createParams(); + + const error = new Error('test'); + + h5pEditor.render.mockRejectedValueOnce(error); + + return { error, content, mockCurrentUser, editorResponseMock, language }; + }; + + it('should return error of service', async () => { + const { error, mockCurrentUser, language } = setup(); + + const getEmptyEditorPromise = uc.getEmptyH5pEditor(mockCurrentUser, language); + + await expect(getEmptyEditorPromise).rejects.toThrow(error); + }); + }); + }); + + describe('getH5pEditor is called', () => { + describe('WHEN user is authorized and service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, contentResponseMock, language } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + h5pEditor.render.mockResolvedValueOnce(editorResponseMock); + h5pEditor.getContent.mockResolvedValueOnce(contentResponseMock); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, language, mockCurrentUser } = setup(); + + await uc.getH5pEditor(mockCurrentUser, content.id, language); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, language, mockCurrentUser } = setup(); + + await uc.getH5pEditor(mockCurrentUser, content.id, language); + + expect(h5pEditor.render).toHaveBeenCalledWith( + content.id, + language, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + expect(h5pEditor.getContent).toHaveBeenCalledWith( + content.id, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); + + it('should return results of service', async () => { + const { content, language, mockCurrentUser, contentResponseMock, editorResponseMock } = setup(); + + const result = await uc.getH5pEditor(mockCurrentUser, content.id, language); + + expect(result).toEqual({ + content: contentResponseMock, + editorModel: editorResponseMock, + }); + }); + }); + + describe('WHEN content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, language } = createParams(); + + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); + + return { content, mockCurrentUser, editorResponseMock, language }; + }; + + it('should throw NotFoundException', async () => { + const { content, mockCurrentUser, language } = setup(); + + const getEditorPromise = uc.getH5pEditor(mockCurrentUser, content.id, language); + + await expect(getEditorPromise).rejects.toThrow(new NotFoundException()); + + expect(h5pEditor.render).toHaveBeenCalledTimes(0); + expect(h5pEditor.getContent).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, contentResponseMock, language } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; + }; + + it('should throw forbidden error', async () => { + const { content, mockCurrentUser, language } = setup(); + + const getEditorPromise = uc.getH5pEditor(mockCurrentUser, content.id, language); + + await expect(getEditorPromise).rejects.toThrow(new ForbiddenException()); + + expect(h5pEditor.render).toHaveBeenCalledTimes(0); + expect(h5pEditor.getContent).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser, editorResponseMock, contentResponseMock, language } = createParams(); + + const error = new Error('test'); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + h5pEditor.render.mockRejectedValueOnce(error); + h5pEditor.getContent.mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { error, content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; + }; + + it('should return error of service', async () => { + const { content, mockCurrentUser, language, error } = setup(); + + const getEditorPromise = uc.getH5pEditor(mockCurrentUser, content.id, language); + + await expect(getEditorPromise).rejects.toThrow(error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts new file mode 100644 index 00000000000..6db9d27a905 --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts @@ -0,0 +1,198 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PEditor, H5PPlayer, IPlayerModel } from '@lumieducation/h5p-server'; +import { Test, TestingModule } from '@nestjs/testing'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { H5PContentRepo } from '../repo'; +import { LibraryStorage } from '../service'; +import { H5PAjaxEndpointProvider } from '../provider'; +import { H5PEditorUc } from './h5p.uc'; + +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', + isExternalUser: false, + }; + + const playerResponseMock = expect.objectContaining({ + contentId: content.id, + }) as IPlayerModel; + + return { content, mockCurrentUser, playerResponseMock }; +}; + +describe('get H5P player', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let h5pPlayer: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationReferenceService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + H5PAjaxEndpointProvider, + { + provide: H5PEditor, + useValue: createMock(), + }, + { + provide: H5PPlayer, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + h5pPlayer = module.get(H5PPlayer); + h5pContentRepo = module.get(H5PContentRepo); + authorizationReferenceService = module.get(AuthorizationReferenceService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getH5pPlayer is called', () => { + describe('WHEN user is authorized and service executes successfully', () => { + const setup = () => { + const { content, mockCurrentUser, playerResponseMock } = createParams(); + + const expectedResult = playerResponseMock; + + h5pContentRepo.findById.mockResolvedValueOnce(content); + h5pPlayer.render.mockResolvedValueOnce(expectedResult); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser, expectedResult }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getH5pPlayer(mockCurrentUser, content.id); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getH5pPlayer(mockCurrentUser, content.id); + + expect(h5pPlayer.render).toHaveBeenCalledWith( + content.id, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); + + it('should return results of service', async () => { + const { content, mockCurrentUser, expectedResult } = setup(); + + const result = await uc.getH5pPlayer(mockCurrentUser, content.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('WHEN content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser, playerResponseMock } = createParams(); + + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); + + return { content, mockCurrentUser, playerResponseMock }; + }; + + it('should throw NotFoundException', async () => { + const { content, mockCurrentUser } = setup(); + + const getPlayerPromise = uc.getH5pPlayer(mockCurrentUser, content.id); + + await expect(getPlayerPromise).rejects.toThrow(new NotFoundException()); + + expect(h5pPlayer.render).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser, playerResponseMock } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { content, mockCurrentUser, playerResponseMock }; + }; + + it('should throw forbidden error', async () => { + const { content, mockCurrentUser } = setup(); + + const getPlayerPromise = uc.getH5pPlayer(mockCurrentUser, content.id); + + await expect(getPlayerPromise).rejects.toThrow(new ForbiddenException()); + + expect(h5pPlayer.render).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { content, mockCurrentUser, playerResponseMock } = createParams(); + + const error = new Error('test'); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + h5pPlayer.render.mockRejectedValueOnce(error); + + return { error, content, mockCurrentUser, playerResponseMock }; + }; + + it('should return error of service', async () => { + const { error, content, mockCurrentUser } = setup(); + + const getPlayerPromise = uc.getH5pPlayer(mockCurrentUser, content.id); + + await expect(getPlayerPromise).rejects.toThrow(error); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts new file mode 100644 index 00000000000..2bd23edecdc --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts @@ -0,0 +1,340 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; +import { Test, TestingModule } from '@nestjs/testing'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ForbiddenException } from '@nestjs/common'; +import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { LibraryStorage } from '../service'; +import { H5PAjaxEndpointProvider } from '../provider'; +import { H5PEditorUc } from './h5p.uc'; +import { H5PContentParentType } from '../entity'; +import { H5PContentRepo } from '../repo'; +import { LumiUserWithContentData } from '../types/lumi-types'; + +const createParams = () => { + const { content: parameters, metadata } = h5pContentFactory.build(); + + const mainLibraryUbername = metadata.mainLibrary; + + const contentId = new ObjectId().toHexString(); + const parentId = new ObjectId().toHexString(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', + isExternalUser: false, + }; + + return { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser }; +}; + +describe('save or create H5P content', () => { + let module: TestingModule; + let uc: H5PEditorUc; + let h5pEditor: DeepMocked; + let authorizationReferenceService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + H5PEditorUc, + H5PAjaxEndpointProvider, + { + provide: H5PEditor, + useValue: createMock(), + }, + { + provide: H5PPlayer, + useValue: createMock(), + }, + { + provide: LibraryStorage, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(H5PEditorUc); + h5pEditor = module.get(H5PEditor); + authorizationReferenceService = module.get(AuthorizationReferenceService); + await setupEntities(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('saveH5pContentGetMetadata is called', () => { + describe('WHEN user is authorized and service saves successfully', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce({ id: contentId, metadata }); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + await uc.saveH5pContentGetMetadata( + contentId, + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + H5PContentParentType.Lesson, + parentId, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should call service with correct params', async () => { + const { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + await uc.saveH5pContentGetMetadata( + contentId, + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(h5pEditor.saveOrUpdateContentReturnMetaData).toHaveBeenCalledWith( + contentId, + parameters, + metadata, + mainLibraryUbername, + expect.any(LumiUserWithContentData) + ); + }); + + it('should return results of service', async () => { + const { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + const result = await uc.saveH5pContentGetMetadata( + contentId, + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(result).toEqual({ id: contentId, metadata }); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; + }; + + it('should throw forbidden error', async () => { + const { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId } = setup(); + + const saveContentPromise = uc.saveH5pContentGetMetadata( + contentId, + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + await expect(saveContentPromise).rejects.toThrow(new ForbiddenException()); + + expect(h5pEditor.saveOrUpdateContentReturnMetaData).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + const error = new Error('test'); + + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + h5pEditor.saveOrUpdateContentReturnMetaData.mockRejectedValueOnce(error); + + return { error, contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; + }; + + it('should return error of service', async () => { + const { error, contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId } = setup(); + + const saveContentPromise = uc.saveH5pContentGetMetadata( + contentId, + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + await expect(saveContentPromise).rejects.toThrow(error); + }); + }); + }); + + describe('createH5pContentGetMetadata is called', () => { + describe('WHEN user is authorized and service creates successfully', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce({ id: contentId, metadata }); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + await uc.createH5pContentGetMetadata( + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + H5PContentParentType.Lesson, + parentId, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should call service with correct params', async () => { + const { parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + await uc.createH5pContentGetMetadata( + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(h5pEditor.saveOrUpdateContentReturnMetaData).toHaveBeenCalledWith( + undefined, + parameters, + metadata, + mainLibraryUbername, + expect.any(LumiUserWithContentData) + ); + }); + + it('should return results of service', async () => { + const { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); + + const result = await uc.createH5pContentGetMetadata( + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + expect(result).toEqual({ id: contentId, metadata }); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; + }; + + it('should throw forbidden error', async () => { + const { mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId } = setup(); + + const saveContentPromise = uc.createH5pContentGetMetadata( + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + await expect(saveContentPromise).rejects.toThrow(new ForbiddenException()); + + expect(h5pEditor.saveOrUpdateContentReturnMetaData).toHaveBeenCalledTimes(0); + }); + }); + + describe('WHEN service throws error', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + const error = new Error('test'); + + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + h5pEditor.saveOrUpdateContentReturnMetaData.mockRejectedValueOnce(error); + + return { error, contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; + }; + + it('should return error of service', async () => { + const { error, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId } = setup(); + + const saveContentPromise = uc.createH5pContentGetMetadata( + mockCurrentUser, + parameters, + metadata, + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId + ); + + await expect(saveContentPromise).rejects.toThrow(error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts new file mode 100644 index 00000000000..f456491509a --- /dev/null +++ b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts @@ -0,0 +1,410 @@ +import { + AjaxSuccessResponse, + H5PAjaxEndpoint, + H5PEditor, + H5PPlayer, + IContentMetadata, + IEditorModel, + IPlayerModel, + IUser as LumiIUser, +} from '@lumieducation/h5p-server'; +import { + IAjaxResponse, + IHubInfo, + ILibraryDetailedDataForClient, + ILibraryOverviewForClient, +} from '@lumieducation/h5p-server/build/src/types'; +import { + BadRequestException, + HttpException, + Injectable, + NotAcceptableException, + NotFoundException, +} from '@nestjs/common'; +import { EntityId, LanguageType } from '@shared/domain'; +import { ICurrentUser } from '@src/modules/authentication'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { UserService } from '@src/modules/user'; +import { Request } from 'express'; +import { AjaxGetQueryParams, AjaxPostBodyParams, AjaxPostQueryParams } from '../controller/dto'; +import { H5PContentParentType } from '../entity'; +import { H5PContentMapper } from '../mapper/h5p-content.mapper'; +import { H5PErrorMapper } from '../mapper/h5p-error.mapper'; +import { H5PContentRepo } from '../repo'; +import { LibraryStorage } from '../service'; +import { LumiUserWithContentData } from '../types/lumi-types'; +import { GetLibraryFile } from './dto/h5p-getLibraryFile'; + +@Injectable() +export class H5PEditorUc { + constructor( + private readonly h5pEditor: H5PEditor, + private readonly h5pPlayer: H5PPlayer, + private readonly h5pAjaxEndpoint: H5PAjaxEndpoint, + private readonly libraryService: LibraryStorage, + private readonly userService: UserService, + private readonly authorizationReferenceService: AuthorizationReferenceService, + private readonly h5pContentRepo: H5PContentRepo + ) {} + + private async checkContentPermission( + userId: EntityId, + parentType: H5PContentParentType, + parentId: EntityId, + context: AuthorizationContext + ) { + const allowedType = H5PContentMapper.mapToAllowedAuthorizationEntityType(parentType); + await this.authorizationReferenceService.checkPermissionByReferences(userId, allowedType, parentId, context); + } + + private fakeUndefinedAsString = () => { + const value = undefined as unknown as string; + return value; + }; + + /** + * Returns a callback that parses the request range. + */ + private getRange(req: Request) { + return (filesize: number) => { + const range = req.range(filesize); + + if (range) { + if (range === -2) { + throw new BadRequestException('invalid range'); + } + + if (range === -1) { + throw new BadRequestException('unsatisfiable range'); + } + + if (range.length > 1) { + throw new BadRequestException('multipart ranges are unsupported'); + } + + return range[0]; + } + + return undefined; + }; + } + + public async getAjax( + query: AjaxGetQueryParams, + currentUser: ICurrentUser + ): Promise { + const user = this.changeUserType(currentUser); + const language = await this.getUserLanguage(currentUser); + const h5pErrorMapper = new H5PErrorMapper(); + + try { + const result = await this.h5pAjaxEndpoint.getAjax( + query.action, + query.machineName, + query.majorVersion, + query.minorVersion, + language, + user + ); + return result; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + h5pErrorMapper.mapH5pError(err); + return undefined; + } + } + + public async postAjax( + currentUser: ICurrentUser, + query: AjaxPostQueryParams, + body: AjaxPostBodyParams, + contentFile?: Express.Multer.File, + h5pFile?: Express.Multer.File + ): Promise< + | AjaxSuccessResponse + | { + height?: number; + mime: string; + path: string; + width?: number; + } + | ILibraryOverviewForClient[] + | undefined + > { + const user = this.changeUserType(currentUser); + const language = await this.getUserLanguage(currentUser); + const h5pErrorMapper = new H5PErrorMapper(); + + try { + const result = await this.h5pAjaxEndpoint.postAjax( + query.action, + body, + language, + user, + contentFile && { + data: contentFile.buffer, + mimetype: contentFile.mimetype, + name: contentFile.originalname, + size: contentFile.size, + }, + query.id, + undefined, + h5pFile && { + data: h5pFile.buffer, + mimetype: h5pFile.mimetype, + name: h5pFile.originalname, + size: h5pFile.size, + }, + undefined // TODO: HubID? + ); + + return result; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + h5pErrorMapper.mapH5pError(err); + return undefined; + } + } + + public async getContentParameters(contentId: string, currentUser: ICurrentUser) { + const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.read([])); + + const user = this.changeUserType(currentUser); + + try { + const result = await this.h5pAjaxEndpoint.getContentParameters(contentId, user); + + return result; + } catch (err) { + throw new NotFoundException(); + } + } + + public async getContentFile( + contentId: string, + file: string, + req: Request, + currentUser: ICurrentUser + ): Promise { + const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.read([])); + + const user = this.changeUserType(currentUser); + + try { + const rangeCallback = this.getRange(req); + const { mimetype, range, stats, stream } = await this.h5pAjaxEndpoint.getContentFile( + contentId, + file, + user, + rangeCallback + ); + + return { + data: stream, + contentType: mimetype, + contentLength: stats.size, + contentRange: range, // Range can be undefined, typings from @lumieducation/h5p-server are wrong + }; + } catch (err) { + throw new NotFoundException(); + } + } + + public async getLibraryFile(ubername: string, file: string): Promise { + try { + const { mimetype, size, stream } = await this.libraryService.getLibraryFile(ubername, file); + + return { + data: stream, + contentType: mimetype, + contentLength: size as number, + }; + } catch (err) { + throw new NotFoundException(); + } + } + + public async getTemporaryFile(file: string, req: Request, currentUser: ICurrentUser): Promise { + const user = this.changeUserType(currentUser); + + try { + const rangeCallback = this.getRange(req); + const adapterRangeCallback: (filesize: number) => { end: number; start: number } = (filesize) => { + let returnValue = { start: 0, end: 0 }; + + if (rangeCallback) { + const result = rangeCallback(filesize); + + if (result) { + returnValue = { start: result.start, end: result.end }; + } + } + + return returnValue; + }; + const { mimetype, range, stats, stream } = await this.h5pAjaxEndpoint.getTemporaryFile( + file, + user, + adapterRangeCallback + ); + + return { + data: stream, + contentType: mimetype, + contentLength: stats.size, + contentRange: range, // Range can be undefined, typings from @lumieducation/h5p-server are wrong + }; + } catch (err) { + throw new NotFoundException(); + } + } + + public async getH5pPlayer(currentUser: ICurrentUser, contentId: string): Promise { + const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.read([])); + + const user = this.changeUserType(currentUser); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const playerModel: IPlayerModel = await this.h5pPlayer.render(contentId, user); + + return playerModel; + } + + public async getEmptyH5pEditor(currentUser: ICurrentUser, language: LanguageType) { + const user = this.changeUserType(currentUser); + const fakeUndefinedString = this.fakeUndefinedAsString(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const createdH5PEditor: IEditorModel = await this.h5pEditor.render( + fakeUndefinedString, // Lumi typings are wrong because they dont "use strict", this method actually accepts both string and undefined + language, + user + ); + + return createdH5PEditor; + } + + public async getH5pEditor(currentUser: ICurrentUser, contentId: string, language: LanguageType) { + const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.changeUserType(currentUser); + + const [editorModel, content] = await Promise.all([ + this.h5pEditor.render(contentId, language, user) as Promise, + this.h5pEditor.getContent(contentId, user), + ]); + + return { + editorModel, + content, + }; + } + + public async deleteH5pContent(currentUser: ICurrentUser, contentId: string): Promise { + const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.changeUserType(currentUser); + let deletedContent = false; + try { + await this.h5pEditor.deleteContent(contentId, user); + deletedContent = true; + } catch (error) { + deletedContent = false; + throw new HttpException('message', 400, { + cause: new NotAcceptableException(error as string, 'content not found'), + }); + } + + return deletedContent; + } + + public async createH5pContentGetMetadata( + currentUser: ICurrentUser, + params: unknown, + metadata: IContentMetadata, + mainLibraryUbername: string, + parentType: H5PContentParentType, + parentId: EntityId + ): Promise<{ id: string; metadata: IContentMetadata }> { + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); + const fakeAsString = this.fakeUndefinedAsString(); + + const newContentId = await this.h5pEditor.saveOrUpdateContentReturnMetaData( + fakeAsString, // Lumi typings are wrong because they dont "use strict", this method actually accepts both string and undefined + params, + metadata, + mainLibraryUbername, + user + ); + + return newContentId; + } + + public async saveH5pContentGetMetadata( + contentId: string, + currentUser: ICurrentUser, + params: unknown, + metadata: IContentMetadata, + mainLibraryUbername: string, + parentType: H5PContentParentType, + parentId: EntityId + ): Promise<{ id: string; metadata: IContentMetadata }> { + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); + + const newContentId = await this.h5pEditor.saveOrUpdateContentReturnMetaData( + contentId, + params, + metadata, + mainLibraryUbername, + user + ); + + return newContentId; + } + + private changeUserType(currentUser: ICurrentUser): LumiIUser { + const user: LumiIUser = { + canCreateRestricted: false, + canInstallRecommended: true, + canUpdateAndInstallLibraries: true, + email: '', + id: currentUser.userId, + name: '', + type: '', + }; + + return user; + } + + private createAugmentedLumiUser( + currentUser: ICurrentUser, + contentParentType: H5PContentParentType, + contentParentId: EntityId + ) { + const user = new LumiUserWithContentData(this.changeUserType(currentUser), { + parentType: contentParentType, + parentId: contentParentId, + schoolId: currentUser.schoolId, + }); + + return user; + } + + private async getUserLanguage(currentUser: ICurrentUser): Promise { + const languageUser = await this.userService.findById(currentUser.userId); + let userLanguage = LanguageType.DE; + if (languageUser?.language) { + userLanguage = languageUser.language; + } + return userLanguage; + } +} diff --git a/apps/server/src/shared/infra/s3-client/interface/index.ts b/apps/server/src/shared/infra/s3-client/interface/index.ts index ad6ed9c81da..d3438099858 100644 --- a/apps/server/src/shared/infra/s3-client/interface/index.ts +++ b/apps/server/src/shared/infra/s3-client/interface/index.ts @@ -26,3 +26,17 @@ export interface File { data: Readable; mimeType: string; } + +export interface ListFiles { + path: string; + maxKeys?: number; + nextMarker?: string; + files?: string[]; +} + +export interface ObjectKeysRecursive { + path: string; + maxKeys: number | undefined; + nextMarker: string | undefined; + files: string[]; +} diff --git a/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts b/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts index 957d841f1fe..87de7afb1c3 100644 --- a/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts +++ b/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts @@ -1,7 +1,7 @@ import { S3Client, S3ServiceException } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { HttpException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ErrorUtils } from '@src/core/error/utils'; import { LegacyLogger } from '@src/core/logger'; @@ -564,4 +564,217 @@ describe('S3ClientAdapter', () => { await expect(service.copy(undefined)).rejects.toThrowError(InternalServerErrorException); }); }); + + describe('head', () => { + const setup = () => { + const { pathToFile } = createParameter(); + + return { pathToFile }; + }; + + describe('when file exists', () => { + it('should call send() of client with head object', async () => { + const { pathToFile } = setup(); + + await service.head(pathToFile); + + expect(client.send).toBeCalledWith( + expect.objectContaining({ + input: { Bucket: 'test-bucket', Key: pathToFile }, + }) + ); + }); + }); + + describe('when file does not exist', () => { + it('should throw HttpException', async () => { + const { pathToFile } = setup(); + // @ts-expect-error ignore parameter type of mock function + client.send.mockRejectedValueOnce(new Error('NoSuchKey')); + + const headPromise = service.head(pathToFile); + + await expect(headPromise).rejects.toBeInstanceOf(HttpException); + }); + }); + describe('when file exist and failed', () => { + it('should throw InternalServerErrorException', async () => { + const { pathToFile } = setup(); + // @ts-expect-error ignore parameter type of mock function + client.send.mockRejectedValueOnce(new Error('Dummy')); + + const headPromise = service.head(pathToFile); + + await expect(headPromise).rejects.toBeInstanceOf(InternalServerErrorException); + }); + }); + }); + + describe('list', () => { + const setup = () => { + const path = 'test/'; + + const keys = Array.from(Array(2500).keys()).map((n) => `KEY-${n}`); + const responseContents = keys.map((key) => { + return { + Key: `${path}${key}`, + }; + }); + + return { path, keys, responseContents }; + }; + + afterEach(() => { + client.send.mockClear(); + }); + + describe('when maxKeys is given', () => { + it('should truncate result', async () => { + const { path, keys, responseContents } = setup(); + + // @ts-expect-error ignore parameter type of mock function + client.send.mockResolvedValue({ + IsTruncated: false, + Contents: responseContents.slice(0, 500), + }); + + const resultKeys = await service.list({ path, maxKeys: 500 }); + + expect(resultKeys.files).toEqual(keys.slice(0, 500)); + + expect(client.send).toBeCalledWith( + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: undefined, + MaxKeys: 500, + }, + }) + ); + }); + + it('should truncate result by S3 limits', async () => { + const { path, keys, responseContents } = setup(); + + // @ts-expect-error ignore parameter type of mock function + client.send.mockResolvedValueOnce({ + IsTruncated: true, + Contents: responseContents.slice(0, 1000), + ContinuationToken: 'KEY-1000', + }); + + // @ts-expect-error ignore parameter type of mock function + client.send.mockResolvedValueOnce({ + IsTruncated: true, + Contents: responseContents.slice(1000, 1200), + ContinuationToken: 'KEY-1200', + }); + + const resultKeys = await service.list({ path, maxKeys: 1200 }); + + expect(resultKeys.files).toEqual(keys.slice(0, 1200)); + + expect(client.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: undefined, + MaxKeys: 1200, + }, + }) + ); + + expect(client.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: 'KEY-1000', + MaxKeys: 200, + }, + }) + ); + + expect(client.send).toHaveBeenCalledTimes(2); + }); + }); + + describe('when maxKeys is not given', () => { + it('should call send() multiple times if bucket contains more than 1000 keys', async () => { + const { path, responseContents, keys } = setup(); + + client.send + // @ts-expect-error ignore parameter type of mock function + .mockResolvedValueOnce({ + IsTruncated: true, + ContinuationToken: '1', + Contents: responseContents.slice(0, 1000), + }) + // @ts-expect-error ignore parameter type of mock function + .mockResolvedValueOnce({ + IsTruncated: true, + ContinuationToken: '2', + Contents: responseContents.slice(1000, 2000), + }) + // @ts-expect-error ignore parameter type of mock function + .mockResolvedValueOnce({ + Contents: responseContents.slice(2000), + }); + + const resultKeys = await service.list({ path }); + + expect(resultKeys.files).toEqual(keys); + + expect(client.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: undefined, + }, + }) + ); + + expect(client.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: '1', + }, + }) + ); + + expect(client.send).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: '2', + }, + }) + ); + }); + }); + + describe('when client rejects with an error', () => { + it('should throw error', async () => { + const { path } = setup(); + + // @ts-expect-error ignore parameter type of mock function + client.send.mockRejectedValue(new Error()); + + const listPromise = service.list({ path }); + + await expect(listPromise).rejects.toThrow(); + }); + }); + }); }); diff --git a/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts b/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts index 1f4d47b5737..3c83fce7413 100644 --- a/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts +++ b/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts @@ -4,7 +4,9 @@ import { CreateBucketCommand, DeleteObjectsCommand, GetObjectCommand, - ListObjectsCommand, + HeadObjectCommand, + HeadObjectCommandOutput, + ListObjectsV2Command, S3Client, ServiceOutputTypes, } from '@aws-sdk/client-s3'; @@ -14,7 +16,7 @@ import { ErrorUtils } from '@src/core/error/utils'; import { LegacyLogger } from '@src/core/logger'; import { Readable } from 'stream'; import { S3_CLIENT, S3_CONFIG } from './constants'; -import { CopyFiles, File, GetFile, S3Config } from './interface'; +import { CopyFiles, File, GetFile, ListFiles, ObjectKeysRecursive, S3Config } from './interface'; @Injectable() export class S3ClientAdapter { @@ -196,11 +198,75 @@ export class S3ClientAdapter { } } + public async list(params: ListFiles): Promise { + try { + this.logger.log({ action: 'list', params }); + + const result = await this.listObjectKeysRecursive(params); + + return result; + } catch (err) { + throw new NotFoundException(null, ErrorUtils.createHttpExceptionOptions(err, 'S3ClientAdapter:listDirectory')); + } + } + + private async listObjectKeysRecursive(params: ListFiles): Promise { + const { path, maxKeys, nextMarker } = params; + let files: string[] = params.files ? params.files : []; + const MaxKeys = maxKeys && maxKeys - files.length; + + const req = new ListObjectsV2Command({ + Bucket: this.config.bucket, + Prefix: path, + ContinuationToken: nextMarker, + MaxKeys, + }); + + const data = await this.client.send(req); + + const returnedFiles = + data?.Contents?.filter((o) => o.Key) + .map((o) => o.Key as string) // Can not be undefined because of filter above + .map((key) => key.substring(path.length)) ?? []; + + files = files.concat(returnedFiles); + + let res: ObjectKeysRecursive = { path, maxKeys, nextMarker: data?.ContinuationToken, files }; + + if (data?.IsTruncated && (!maxKeys || res.files.length < maxKeys)) { + res = await this.listObjectKeysRecursive(res); + } + + return res; + } + + public async head(path: string): Promise { + try { + this.logger.log({ action: 'head', params: { path, bucket: this.config.bucket } }); + + const req = new HeadObjectCommand({ + Bucket: this.config.bucket, + Key: path, + }); + + const headResponse = await this.client.send(req); + + return headResponse; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (err.message && err.message === 'NoSuchKey') { + this.logger.log(`could not find the file for head with id ${path}`); + throw new NotFoundException(null, ErrorUtils.createHttpExceptionOptions(err, 'NoSuchKey')); + } + throw new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(err, 'S3ClientAdapter:head')); + } + } + public async deleteDirectory(path: string) { try { this.logger.log({ action: 'deleteDirectory', params: { path, bucket: this.config.bucket } }); - const req = new ListObjectsCommand({ + const req = new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: path, }); diff --git a/apps/server/src/shared/testing/factory/h5p-content.factory.ts b/apps/server/src/shared/testing/factory/h5p-content.factory.ts new file mode 100644 index 00000000000..4d07c369cd5 --- /dev/null +++ b/apps/server/src/shared/testing/factory/h5p-content.factory.ts @@ -0,0 +1,36 @@ +import { + ContentMetadata, + H5PContent, + H5PContentParentType, + IH5PContentProperties, +} from '@src/modules/h5p-editor/entity'; +import { ObjectID } from 'bson'; +import { BaseFactory } from './base.factory'; + +class H5PContentFactory extends BaseFactory {} + +export const h5pContentFactory = H5PContentFactory.define(H5PContent, ({ sequence }) => { + return { + parentType: H5PContentParentType.Lesson, + parentId: new ObjectID().toHexString(), + creatorId: new ObjectID().toHexString(), + schoolId: new ObjectID().toHexString(), + content: { + [`field${sequence}`]: sequence, + dateField: new Date(sequence), + thisObjectHasNoStructure: true, + nested: { + works: true, + }, + }, + metadata: new ContentMetadata({ + defaultLanguage: 'de-de', + embedTypes: ['iframe'], + language: 'de-de', + license: `License #${sequence}`, + mainLibrary: `Library-${sequence}.0`, + preloadedDependencies: [], + title: `Title #${sequence}`, + }), + }; +}); diff --git a/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts b/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts new file mode 100644 index 00000000000..4c9fbea5b11 --- /dev/null +++ b/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts @@ -0,0 +1,25 @@ +import { ITemporaryFileProperties, H5pEditorTempFile } from '@src/modules/h5p-editor/entity'; +import { DeepPartial } from 'fishery'; +import { BaseFactory } from './base.factory'; + +const oneDay = 24 * 60 * 60 * 1000; + +class H5PTemporaryFileFactory extends BaseFactory { + isExpired(): this { + const birthtime = new Date(Date.now() - oneDay * 2); // Created two days ago + const expiresAt = new Date(Date.now() - oneDay); // Expired yesterday + const params: DeepPartial = { expiresAt, birthtime }; + + return this.params(params); + } +} + +export const h5pTemporaryFileFactory = H5PTemporaryFileFactory.define(H5pEditorTempFile, ({ sequence }) => { + return { + filename: `File-${sequence}.txt`, + ownedByUserId: `user-${sequence}`, + birthtime: new Date(Date.now() - oneDay), // Yesterday + expiresAt: new Date(Date.now() + oneDay), // Tomorrow + size: sequence, + }; +}); diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 7d5ec2ab753..bd8d0913a72 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -15,6 +15,8 @@ export * from './external-tool-pseudonym.factory'; export * from './federal-state.factory'; export * from './filerecord.factory'; export * from './group-entity.factory'; +export * from './h5p-content.factory'; +export * from './h5p-temporary-file.factory'; export * from './import-user.factory'; export * from './lesson.factory'; export * from './material.factory'; diff --git a/config/development.json b/config/development.json index 43d1b18640f..eb106993b10 100644 --- a/config/development.json +++ b/config/development.json @@ -31,6 +31,13 @@ "S3_ACCESS_KEY": "S3RVER", "S3_SECRET_KEY": "S3RVER" }, + "H5P_EDITOR": { + "S3_ENDPOINT": "http://localhost:5678", + "S3_REGION": "eu-central-1", + "S3_ACCESS_KEY_ID": "S3RVER", + "S3_SECRET_ACCESS_KEY": "S3RVER", + "S3_BUCKET_TEMP_FILES": "h5p-temp-files" + }, "FEATURE_IDENTITY_MANAGEMENT_ENABLED": true, "IDENTITY_MANAGEMENT": { "URI": "http://localhost:8080", diff --git a/package-lock.json b/package-lock.json index cd0606b9fde..5f895871520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,8 @@ "freeport": "^1.0.5", "gm": "^1.25.0", "html-entities": "^2.3.2", + "i18next": "^23.3.0", + "i18next-fs-backend": "^2.1.5", "jose": "^1.28.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", @@ -2355,10 +2357,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", - "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", - "dev": true, + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", + "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -12737,6 +12738,33 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "23.3.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.3.0.tgz", + "integrity": "sha512-xd/UzWT71zYudCT7qVn6tB4yUVuXAhgCorsowYgM2EOdc14WqQBp5P2wEsxgfiDgdLN5XwJvTbzxrMfoY/nxnw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.22.5" + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.1.5.tgz", + "integrity": "sha512-7fgSH8nVhXSBYPHR/W3tEXXhcnwHwNiND4Dfx9knzPzdsWTUTL/TdDVV+DY0dL0asHKLbdoJaXS4LdVW6R8MVQ==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -20580,8 +20608,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regexp-clone": { "version": "1.0.0", @@ -26659,10 +26686,9 @@ } }, "@babel/runtime": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", - "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", - "dev": true, + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", + "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", "requires": { "regenerator-runtime": "^0.13.11" } @@ -34395,6 +34421,19 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "i18next": { + "version": "23.3.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.3.0.tgz", + "integrity": "sha512-xd/UzWT71zYudCT7qVn6tB4yUVuXAhgCorsowYgM2EOdc14WqQBp5P2wEsxgfiDgdLN5XwJvTbzxrMfoY/nxnw==", + "requires": { + "@babel/runtime": "^7.22.5" + } + }, + "i18next-fs-backend": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.1.5.tgz", + "integrity": "sha512-7fgSH8nVhXSBYPHR/W3tEXXhcnwHwNiND4Dfx9knzPzdsWTUTL/TdDVV+DY0dL0asHKLbdoJaXS4LdVW6R8MVQ==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -40378,8 +40417,7 @@ "regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regexp-clone": { "version": "1.0.0", diff --git a/package.json b/package.json index 45e150f6668..6afd927c17c 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,8 @@ "freeport": "^1.0.5", "gm": "^1.25.0", "html-entities": "^2.3.2", + "i18next": "^23.3.0", + "i18next-fs-backend": "^2.1.5", "jose": "^1.28.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5",