diff --git a/.eslintrc.js b/.eslintrc.js index 008320274fb..a1851003d0d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -65,22 +65,24 @@ module.exports = { overrides: [ { files: ['apps/**/*.ts'], + env: { + node: true, + es6: true, + }, parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint/eslint-plugin'], + parserOptions: { + project: 'apps/server/tsconfig.lint.json', + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin', 'import'], extends: [ 'airbnb-typescript/base', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', 'prettier', 'plugin:promise/recommended', + 'plugin:import/typescript', ], - parserOptions: { - project: 'apps/server/tsconfig.lint.json', - }, - env: { - node: true, - es6: true, - }, rules: { 'import/no-unresolved': 'off', // better handled by ts resolver 'import/no-extraneous-dependencies': 'off', // better handles by ts resolver @@ -98,6 +100,17 @@ module.exports = { allowSingleExtends: true, }, ], + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@infra/*/*', '@modules/*/*', '!*.module'], + message: 'Do not deep import from a module', + }, + ], + }, + ], }, overrides: [ { 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/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index a3e5459077f..654d4152b95 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -390,8 +390,8 @@ data: }' # Add Bettermarks' tools configuration as an external tool - # (stored in the 'external_tools' collection) that uses OAuth. - mongosh $DATABASE__URL --eval 'db.external_tools.replaceOne( + # (stored in the 'external-tools' collection) that uses OAuth. + mongosh $DATABASE__URL --eval 'db.external-tools.replaceOne( { "name": "bettermarks", "config_type": "oauth2" @@ -486,9 +486,9 @@ data: echo "POSTed nextcloud to hydra." # Add Nextcloud' tools configuration as an external tool - # (stored in the 'external_tools' collection) that uses OAuth. - echo "Inserting nextcloud to external_tools..." - mongosh $DATABASE__URL --eval 'db.external_tools.update( + # (stored in the 'external-tools' collection) that uses OAuth. + echo "Inserting nextcloud to external-tools..." + mongosh $DATABASE__URL --eval 'db.external-tools.update( { "name": "nextcloud", "config_type": "oauth2" @@ -512,7 +512,7 @@ data: "upsert": true } );' - echo "Inserted nextcloud to external_tools." + echo "Inserted nextcloud to external-tools." echo "Nextcloud config data init performed successfully." fi diff --git a/apps/server/doc/summary.json b/apps/server/doc/summary.json index 834b92cb5f4..2c7489c537a 100644 --- a/apps/server/doc/summary.json +++ b/apps/server/doc/summary.json @@ -61,7 +61,7 @@ }, { "title": "S3ClientModule", - "file": "../src/shared/infra/s3-client/README.md" + "file": "../src/infra/s3-client/README.md" } ] } 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/apps/helpers/prometheus-metrics.spec.ts b/apps/server/src/apps/helpers/prometheus-metrics.spec.ts index 0c4530f99b8..396fc4865b6 100644 --- a/apps/server/src/apps/helpers/prometheus-metrics.spec.ts +++ b/apps/server/src/apps/helpers/prometheus-metrics.spec.ts @@ -5,7 +5,7 @@ import { PrometheusMetricsConfig, createAPIResponseTimeMetricMiddleware, createPrometheusMetricsApp, -} from '@shared/infra/metrics'; +} from '@infra/metrics'; import { Logger } from '@src/core/logger'; import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; import { @@ -15,9 +15,9 @@ import { createAndStartPrometheusMetricsAppIfEnabled, } from './prometheus-metrics'; -jest.mock('@shared/infra/metrics', () => { +jest.mock('@infra/metrics', () => { const moduleMock: unknown = { - ...jest.requireActual('@shared/infra/metrics'), + ...jest.requireActual('@infra/metrics'), createAPIResponseTimeMetricMiddleware: jest.fn(), createPrometheusMetricsApp: jest.fn(), }; diff --git a/apps/server/src/apps/helpers/prometheus-metrics.ts b/apps/server/src/apps/helpers/prometheus-metrics.ts index 751cada4c2f..56d04b85d89 100644 --- a/apps/server/src/apps/helpers/prometheus-metrics.ts +++ b/apps/server/src/apps/helpers/prometheus-metrics.ts @@ -4,7 +4,7 @@ import { PrometheusMetricsConfig, createAPIResponseTimeMetricMiddleware, createPrometheusMetricsApp, -} from '@shared/infra/metrics'; +} from '@infra/metrics'; import { LogMessage, Loggable, Logger } from '@src/core/logger'; import { AppStartLoggable } from './app-start-loggable'; diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index bd235d5261f..6322bcd568f 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -5,9 +5,9 @@ import { MikroORM } from '@mikro-orm/core'; import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { Mail, MailService } from '@shared/infra/mail'; +import { Mail, MailService } from '@infra/mail'; import { LegacyLogger, Logger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; +import { AccountService } from '@modules/account'; import { TeamService } from '@modules/teams/service/team.service'; import { AccountValidationService } from '@modules/account/services/account.validation.service'; import { AccountUc } from '@modules/account/uc/account.uc'; diff --git a/apps/server/src/console/api-test/database-management.console.api.spec.ts b/apps/server/src/console/api-test/database-management.console.api.spec.ts index 6a2a8de7e7f..ea3cc340616 100644 --- a/apps/server/src/console/api-test/database-management.console.api.spec.ts +++ b/apps/server/src/console/api-test/database-management.console.api.spec.ts @@ -1,5 +1,5 @@ import { INestApplicationContext } from '@nestjs/common'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { ServerConsoleModule } from '@src/console/console.module'; import { CommanderError } from 'commander'; import { BootstrapConsole, ConsoleService } from 'nestjs-console'; diff --git a/apps/server/src/console/api-test/server-console.api.spec.ts b/apps/server/src/console/api-test/server-console.api.spec.ts index 0621a809c36..d9b71bd2fbc 100644 --- a/apps/server/src/console/api-test/server-console.api.spec.ts +++ b/apps/server/src/console/api-test/server-console.api.spec.ts @@ -2,7 +2,7 @@ import { INestApplicationContext } from '@nestjs/common'; import { BootstrapConsole, ConsoleService } from 'nestjs-console'; import { ServerConsoleModule } from '@src/console/console.module'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { execute, TestBootstrapConsole } from './test-bootstrap.console'; describe('ServerConsole (API)', () => { diff --git a/apps/server/src/console/api-test/test-bootstrap.console.ts b/apps/server/src/console/api-test/test-bootstrap.console.ts index edb196b6a54..f346720bd22 100644 --- a/apps/server/src/console/api-test/test-bootstrap.console.ts +++ b/apps/server/src/console/api-test/test-bootstrap.console.ts @@ -1,6 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { DatabaseManagementUc } from '@modules/management/uc/database-management.uc'; import { AbstractBootstrapConsole, BootstrapConsole } from 'nestjs-console'; diff --git a/apps/server/src/console/console.module.ts b/apps/server/src/console/console.module.ts index 2cad08943eb..9d30db8fedb 100644 --- a/apps/server/src/console/console.module.ts +++ b/apps/server/src/console/console.module.ts @@ -4,8 +4,8 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain'; -import { ConsoleWriterModule } from '@shared/infra/console/console-writer/console-writer.module'; -import { KeycloakModule } from '@shared/infra/identity-management/keycloak/keycloak.module'; +import { ConsoleWriterModule } from '@infra/console/console-writer/console-writer.module'; +import { KeycloakModule } from '@infra/identity-management/keycloak/keycloak.module'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { FilesModule } from '@modules/files'; import { FileEntity } from '@modules/files/entity'; diff --git a/apps/server/src/console/server.console.spec.ts b/apps/server/src/console/server.console.spec.ts index 60efe0c962f..46ba3fff065 100644 --- a/apps/server/src/console/server.console.spec.ts +++ b/apps/server/src/console/server.console.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { ServerConsoleModule } from './console.module'; import { ServerConsole } from './server.console'; diff --git a/apps/server/src/console/server.console.ts b/apps/server/src/console/server.console.ts index 0b0d0b45c4a..32ff8182241 100644 --- a/apps/server/src/console/server.console.ts +++ b/apps/server/src/console/server.console.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ import { Command, Console } from 'nestjs-console'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; @Console({ command: 'server', description: 'sample server console' }) export class ServerConsole { diff --git a/apps/server/src/core/error/filter/global-error.filter.ts b/apps/server/src/core/error/filter/global-error.filter.ts index 56760b18dd9..314c8247d18 100644 --- a/apps/server/src/core/error/filter/global-error.filter.ts +++ b/apps/server/src/core/error/filter/global-error.filter.ts @@ -1,6 +1,6 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, InternalServerErrorException } from '@nestjs/common'; import { ApiValidationError, BusinessError } from '@shared/common'; -import { IError, RpcMessage } from '@shared/infra/rabbitmq/rpc-message'; +import { IError, RpcMessage } from '@infra/rabbitmq/rpc-message'; import { ErrorLogger, Loggable } from '@src/core/logger'; import { LoggingUtils } from '@src/core/logger/logging.utils'; import { Response } from 'express'; diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts new file mode 100644 index 00000000000..f2b480a4bf7 --- /dev/null +++ b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts @@ -0,0 +1,32 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { AxiosErrorLoggable } from './axios-error.loggable'; + +describe(AxiosErrorLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const type = 'mockType'; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + + const axiosErrorLoggable = new AxiosErrorLoggable(axiosError, type); + + return { axiosErrorLoggable, error, axiosError }; + }; + + it('should return error log message', () => { + const { axiosErrorLoggable, error, axiosError } = setup(); + + const result = axiosErrorLoggable.getLogMessage(); + + expect(result).toEqual({ + type: 'mockType', + message: axiosError.message, + data: JSON.stringify(error), + stack: 'mockStack', + }); + }); + }); +}); diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.ts b/apps/server/src/core/error/loggable/axios-error.loggable.ts new file mode 100644 index 00000000000..29e6ad32dad --- /dev/null +++ b/apps/server/src/core/error/loggable/axios-error.loggable.ts @@ -0,0 +1,20 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { AxiosError } from 'axios'; + +export class AxiosErrorLoggable extends HttpException implements Loggable { + constructor(private readonly axiosError: AxiosError, protected readonly type: string) { + super(JSON.stringify(axiosError.response?.data), axiosError.status ?? HttpStatus.INTERNAL_SERVER_ERROR, { + cause: axiosError.cause, + }); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: this.axiosError.message, + type: this.type, + data: JSON.stringify(this.axiosError.response?.data), + stack: this.axiosError.stack, + }; + } +} diff --git a/apps/server/src/core/error/loggable/index.ts b/apps/server/src/core/error/loggable/index.ts new file mode 100644 index 00000000000..0470cbee690 --- /dev/null +++ b/apps/server/src/core/error/loggable/index.ts @@ -0,0 +1,2 @@ +export * from './error.loggable'; +export * from './axios-error.loggable'; diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts b/apps/server/src/infra/antivirus/antivirus.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts rename to apps/server/src/infra/antivirus/antivirus.module.spec.ts diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.ts b/apps/server/src/infra/antivirus/antivirus.module.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/antivirus.module.ts rename to apps/server/src/infra/antivirus/antivirus.module.ts diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts b/apps/server/src/infra/antivirus/antivirus.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts rename to apps/server/src/infra/antivirus/antivirus.service.spec.ts diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.ts b/apps/server/src/infra/antivirus/antivirus.service.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/antivirus.service.ts rename to apps/server/src/infra/antivirus/antivirus.service.ts diff --git a/apps/server/src/shared/infra/antivirus/index.ts b/apps/server/src/infra/antivirus/index.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/index.ts rename to apps/server/src/infra/antivirus/index.ts diff --git a/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts b/apps/server/src/infra/antivirus/interfaces/antivirus.ts similarity index 100% rename from apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts rename to apps/server/src/infra/antivirus/interfaces/antivirus.ts diff --git a/apps/server/src/shared/infra/antivirus/interfaces/index.ts b/apps/server/src/infra/antivirus/interfaces/index.ts similarity index 96% rename from apps/server/src/shared/infra/antivirus/interfaces/index.ts rename to apps/server/src/infra/antivirus/interfaces/index.ts index 6c4771f9cd5..b41764286b4 100644 --- a/apps/server/src/shared/infra/antivirus/interfaces/index.ts +++ b/apps/server/src/infra/antivirus/interfaces/index.ts @@ -1 +1 @@ -export * from './antivirus'; +export * from './antivirus'; diff --git a/apps/server/src/shared/infra/cache/cache.module.ts b/apps/server/src/infra/cache/cache.module.ts similarity index 100% rename from apps/server/src/shared/infra/cache/cache.module.ts rename to apps/server/src/infra/cache/cache.module.ts diff --git a/apps/server/src/shared/infra/cache/index.ts b/apps/server/src/infra/cache/index.ts similarity index 100% rename from apps/server/src/shared/infra/cache/index.ts rename to apps/server/src/infra/cache/index.ts diff --git a/apps/server/src/shared/infra/cache/interface/cache-store-type.enum.ts b/apps/server/src/infra/cache/interface/cache-store-type.enum.ts similarity index 100% rename from apps/server/src/shared/infra/cache/interface/cache-store-type.enum.ts rename to apps/server/src/infra/cache/interface/cache-store-type.enum.ts diff --git a/apps/server/src/shared/infra/cache/interface/index.ts b/apps/server/src/infra/cache/interface/index.ts similarity index 100% rename from apps/server/src/shared/infra/cache/interface/index.ts rename to apps/server/src/infra/cache/interface/index.ts diff --git a/apps/server/src/shared/infra/cache/service/cache.service.ts b/apps/server/src/infra/cache/service/cache.service.ts similarity index 100% rename from apps/server/src/shared/infra/cache/service/cache.service.ts rename to apps/server/src/infra/cache/service/cache.service.ts diff --git a/apps/server/src/shared/infra/calendar/calendar.module.ts b/apps/server/src/infra/calendar/calendar.module.ts similarity index 58% rename from apps/server/src/shared/infra/calendar/calendar.module.ts rename to apps/server/src/infra/calendar/calendar.module.ts index feb0611fcdc..b4eddacef92 100644 --- a/apps/server/src/shared/infra/calendar/calendar.module.ts +++ b/apps/server/src/infra/calendar/calendar.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; -import { CalendarService } from '@shared/infra/calendar/service/calendar.service'; -import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; +import { CalendarService } from './service/calendar.service'; +import { CalendarMapper } from './mapper/calendar.mapper'; @Module({ imports: [HttpModule], diff --git a/apps/server/src/shared/infra/calendar/dto/calendar-event.dto.ts b/apps/server/src/infra/calendar/dto/calendar-event.dto.ts similarity index 100% rename from apps/server/src/shared/infra/calendar/dto/calendar-event.dto.ts rename to apps/server/src/infra/calendar/dto/calendar-event.dto.ts diff --git a/apps/server/src/shared/infra/calendar/index.ts b/apps/server/src/infra/calendar/index.ts similarity index 100% rename from apps/server/src/shared/infra/calendar/index.ts rename to apps/server/src/infra/calendar/index.ts diff --git a/apps/server/src/shared/infra/calendar/interface/calendar-event.interface.ts b/apps/server/src/infra/calendar/interface/calendar-event.interface.ts similarity index 100% rename from apps/server/src/shared/infra/calendar/interface/calendar-event.interface.ts rename to apps/server/src/infra/calendar/interface/calendar-event.interface.ts diff --git a/apps/server/src/shared/infra/calendar/mapper/calendar.mapper.spec.ts b/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts similarity index 80% rename from apps/server/src/shared/infra/calendar/mapper/calendar.mapper.spec.ts rename to apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts index 512173c8ccb..9679ce0a4fa 100644 --- a/apps/server/src/shared/infra/calendar/mapper/calendar.mapper.spec.ts +++ b/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts @@ -1,6 +1,6 @@ -import { ICalendarEvent } from '@shared/infra/calendar/interface/calendar-event.interface'; +import { ICalendarEvent } from '@infra/calendar/interface/calendar-event.interface'; import { Test, TestingModule } from '@nestjs/testing'; -import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; +import { CalendarMapper } from './calendar.mapper'; describe('CalendarMapper', () => { let module: TestingModule; diff --git a/apps/server/src/shared/infra/calendar/mapper/calendar.mapper.ts b/apps/server/src/infra/calendar/mapper/calendar.mapper.ts similarity index 62% rename from apps/server/src/shared/infra/calendar/mapper/calendar.mapper.ts rename to apps/server/src/infra/calendar/mapper/calendar.mapper.ts index 8a71dbcb75f..a75ff01eae6 100644 --- a/apps/server/src/shared/infra/calendar/mapper/calendar.mapper.ts +++ b/apps/server/src/infra/calendar/mapper/calendar.mapper.ts @@ -1,6 +1,6 @@ -import { ICalendarEvent } from '@shared/infra/calendar/interface/calendar-event.interface'; +import { ICalendarEvent } from '@infra/calendar/interface/calendar-event.interface'; import { Injectable } from '@nestjs/common'; -import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto'; +import { CalendarEventDto } from '../dto/calendar-event.dto'; @Injectable() export class CalendarMapper { diff --git a/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts b/apps/server/src/infra/calendar/service/calendar.service.spec.ts similarity index 90% rename from apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts rename to apps/server/src/infra/calendar/service/calendar.service.spec.ts index 43ca5ad06d4..ed6bb4620bc 100644 --- a/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts +++ b/apps/server/src/infra/calendar/service/calendar.service.spec.ts @@ -3,12 +3,12 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; -import { ICalendarEvent } from '@shared/infra/calendar/interface/calendar-event.interface'; -import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; +import { CalendarEventDto, CalendarService } from '@infra/calendar'; import { axiosResponseFactory } from '@shared/testing'; import { AxiosResponse } from 'axios'; import { of, throwError } from 'rxjs'; +import { CalendarMapper } from '../mapper/calendar.mapper'; +import { ICalendarEvent } from '../interface/calendar-event.interface'; describe('CalendarServiceSpec', () => { let module: TestingModule; diff --git a/apps/server/src/shared/infra/calendar/service/calendar.service.ts b/apps/server/src/infra/calendar/service/calendar.service.ts similarity index 91% rename from apps/server/src/shared/infra/calendar/service/calendar.service.ts rename to apps/server/src/infra/calendar/service/calendar.service.ts index b79564634a5..3bf2a6576be 100644 --- a/apps/server/src/shared/infra/calendar/service/calendar.service.ts +++ b/apps/server/src/infra/calendar/service/calendar.service.ts @@ -2,12 +2,12 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto'; -import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; import { ErrorUtils } from '@src/core/error/utils'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { Observable, firstValueFrom } from 'rxjs'; import { URL, URLSearchParams } from 'url'; +import { CalendarMapper } from '../mapper/calendar.mapper'; +import { CalendarEventDto } from '../dto/calendar-event.dto'; import { ICalendarEvent } from '../interface/calendar-event.interface'; @Injectable() diff --git a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage-adapter.module.ts b/apps/server/src/infra/collaborative-storage/collaborative-storage-adapter.module.ts similarity index 75% rename from apps/server/src/shared/infra/collaborative-storage/collaborative-storage-adapter.module.ts rename to apps/server/src/infra/collaborative-storage/collaborative-storage-adapter.module.ts index 84e4f4596d6..f60ff664654 100644 --- a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage-adapter.module.ts +++ b/apps/server/src/infra/collaborative-storage/collaborative-storage-adapter.module.ts @@ -1,14 +1,14 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpModule } from '@nestjs/axios'; import { Module, Provider } from '@nestjs/common'; -import { CollaborativeStorageAdapterMapper } from '@shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper'; -import { NextcloudClient } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client'; -import { NextcloudStrategy } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy'; import { LtiToolRepo } from '@shared/repo/ltitool/'; import { LoggerModule } from '@src/core/logger'; import { ToolModule } from '@modules/tool'; import { PseudonymModule } from '@modules/pseudonym'; import { UserModule } from '@modules/user'; +import { NextcloudStrategy } from './strategy/nextcloud/nextcloud.strategy'; +import { NextcloudClient } from './strategy/nextcloud/nextcloud.client'; +import { CollaborativeStorageAdapterMapper } from './mapper'; import { CollaborativeStorageAdapter } from './collaborative-storage.adapter'; const storageStrategy: Provider = { diff --git a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.spec.ts b/apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.spec.ts similarity index 89% rename from apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.spec.ts rename to apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.spec.ts index c33f9be282a..3b9ba2175e6 100644 --- a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.spec.ts +++ b/apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.spec.ts @@ -2,11 +2,11 @@ import { createMock } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain'; -import { CollaborativeStorageAdapter } from '@shared/infra/collaborative-storage/collaborative-storage.adapter'; -import { CollaborativeStorageAdapterMapper } from '@shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper'; -import { ICollaborativeStorageStrategy } from '@shared/infra/collaborative-storage/strategy/base.interface.strategy'; import { LegacyLogger } from '@src/core/logger'; -import { TeamDto } from '@modules/collaborative-storage/services/dto/team.dto'; +import { TeamDto } from '@modules/collaborative-storage/services/dto/team.dto'; // invalid import please fix +import { CollaborativeStorageAdapter } from './collaborative-storage.adapter'; +import { CollaborativeStorageAdapterMapper } from './mapper/collaborative-storage-adapter.mapper'; +import { ICollaborativeStorageStrategy } from './strategy/base.interface.strategy'; class TestStrategy implements ICollaborativeStorageStrategy { baseURL: string; diff --git a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.ts b/apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.ts similarity index 88% rename from apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.ts rename to apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.ts index 9edcafbdc12..b50657f393e 100644 --- a/apps/server/src/shared/infra/collaborative-storage/collaborative-storage.adapter.ts +++ b/apps/server/src/infra/collaborative-storage/collaborative-storage.adapter.ts @@ -1,10 +1,10 @@ import { TeamPermissionsDto } from '@modules/collaborative-storage/services/dto/team-permissions.dto'; import { TeamDto } from '@modules/collaborative-storage/services/dto/team.dto'; -import { ICollaborativeStorageStrategy } from '@shared/infra/collaborative-storage/strategy/base.interface.strategy'; import { Inject, Injectable } from '@nestjs/common'; -import { CollaborativeStorageAdapterMapper } from '@shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper'; import { LegacyLogger } from '@src/core/logger'; import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { CollaborativeStorageAdapterMapper } from './mapper/collaborative-storage-adapter.mapper'; +import { ICollaborativeStorageStrategy } from './strategy/base.interface.strategy'; /** * Provides an Adapter to an external collaborative storage. diff --git a/apps/server/src/shared/infra/collaborative-storage/dto/team-role-permissions.dto.ts b/apps/server/src/infra/collaborative-storage/dto/team-role-permissions.dto.ts similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/dto/team-role-permissions.dto.ts rename to apps/server/src/infra/collaborative-storage/dto/team-role-permissions.dto.ts diff --git a/apps/server/src/infra/collaborative-storage/index.ts b/apps/server/src/infra/collaborative-storage/index.ts new file mode 100644 index 00000000000..49dea4522b3 --- /dev/null +++ b/apps/server/src/infra/collaborative-storage/index.ts @@ -0,0 +1,2 @@ +export { CollaborativeStorageAdapter } from './collaborative-storage.adapter'; +export { CollaborativeStorageAdapterModule } from './collaborative-storage-adapter.module'; diff --git a/apps/server/src/shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts b/apps/server/src/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts similarity index 89% rename from apps/server/src/shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts rename to apps/server/src/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts index cba6ef365c2..bea98c50ebe 100644 --- a/apps/server/src/shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts +++ b/apps/server/src/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain'; -import { CollaborativeStorageAdapterMapper } from '@shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper'; +import { CollaborativeStorageAdapterMapper } from '@infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper'; describe('TeamStorage Mapper', () => { let module: TestingModule; diff --git a/apps/server/src/shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.ts b/apps/server/src/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.ts similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.ts rename to apps/server/src/infra/collaborative-storage/mapper/collaborative-storage-adapter.mapper.ts diff --git a/apps/server/src/infra/collaborative-storage/mapper/index.ts b/apps/server/src/infra/collaborative-storage/mapper/index.ts new file mode 100644 index 00000000000..08f05350be2 --- /dev/null +++ b/apps/server/src/infra/collaborative-storage/mapper/index.ts @@ -0,0 +1 @@ +export * from './collaborative-storage-adapter.mapper'; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/base.interface.strategy.ts b/apps/server/src/infra/collaborative-storage/strategy/base.interface.strategy.ts similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/strategy/base.interface.strategy.ts rename to apps/server/src/infra/collaborative-storage/strategy/base.interface.strategy.ts diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/httpRequests/NextcloudGroups.http b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/httpRequests/NextcloudGroups.http similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/httpRequests/NextcloudGroups.http rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/httpRequests/NextcloudGroups.http diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts similarity index 99% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts index 5aa4cb8b09d..225ddac258d 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts +++ b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts @@ -3,11 +3,11 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { NotFoundException, NotImplementedException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { NextcloudClient } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; import { Observable, of } from 'rxjs'; +import { NextcloudClient } from './nextcloud.client'; import { GroupUsers, GroupfoldersCreated, diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts similarity index 99% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts index 75caf1bc0e4..ab4a139224c 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts +++ b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts @@ -15,7 +15,7 @@ import { NextcloudGroups, OcsResponse, SuccessfulRes, -} from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.interface'; +} from '@infra/collaborative-storage/strategy/nextcloud/nextcloud.interface'; import { ErrorUtils } from '@src/core/error/utils'; import { LegacyLogger } from '@src/core/logger'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.interface.ts b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.interface.ts similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.interface.ts rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.interface.ts diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts similarity index 98% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts index 7684b14dbb0..706e360b700 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts +++ b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts @@ -3,9 +3,6 @@ import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiPrivacyPermission, LtiRoleType, Pseudonym, RoleName, User, UserDO } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { TeamRolePermissionsDto } from '@shared/infra/collaborative-storage/dto/team-role-permissions.dto'; -import { NextcloudClient } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client'; -import { NextcloudStrategy } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy'; import { LtiToolRepo } from '@shared/repo'; import { ltiToolDOFactory, pseudonymFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; @@ -13,6 +10,9 @@ import { TeamDto, TeamUserDto } from '@modules/collaborative-storage/services/dt import { PseudonymService } from '@modules/pseudonym'; import { ExternalToolService } from '@modules/tool/external-tool/service'; import { UserService } from '@modules/user'; +import { NextcloudStrategy } from './nextcloud.strategy'; +import { NextcloudClient } from './nextcloud.client'; +import { TeamRolePermissionsDto } from '../../dto/team-role-permissions.dto'; class NextcloudStrategySpec extends NextcloudStrategy { static specGenerateGroupId(dto: TeamRolePermissionsDto): string { diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/test.json b/apps/server/src/infra/collaborative-storage/strategy/nextcloud/test.json similarity index 100% rename from apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/test.json rename to apps/server/src/infra/collaborative-storage/strategy/nextcloud/test.json diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.module.spec.ts b/apps/server/src/infra/console/console-writer/console-writer.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/console/console-writer/console-writer.module.spec.ts rename to apps/server/src/infra/console/console-writer/console-writer.module.spec.ts diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.module.ts b/apps/server/src/infra/console/console-writer/console-writer.module.ts similarity index 100% rename from apps/server/src/shared/infra/console/console-writer/console-writer.module.ts rename to apps/server/src/infra/console/console-writer/console-writer.module.ts diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts b/apps/server/src/infra/console/console-writer/console-writer.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts rename to apps/server/src/infra/console/console-writer/console-writer.service.spec.ts diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.service.ts b/apps/server/src/infra/console/console-writer/console-writer.service.ts similarity index 100% rename from apps/server/src/shared/infra/console/console-writer/console-writer.service.ts rename to apps/server/src/infra/console/console-writer/console-writer.service.ts diff --git a/apps/server/src/shared/infra/console/console-writer/index.ts b/apps/server/src/infra/console/console-writer/index.ts similarity index 100% rename from apps/server/src/shared/infra/console/console-writer/index.ts rename to apps/server/src/infra/console/console-writer/index.ts diff --git a/apps/server/src/shared/infra/console/index.ts b/apps/server/src/infra/console/index.ts similarity index 100% rename from apps/server/src/shared/infra/console/index.ts rename to apps/server/src/infra/console/index.ts diff --git a/apps/server/src/shared/infra/database/index.ts b/apps/server/src/infra/database/index.ts similarity index 100% rename from apps/server/src/shared/infra/database/index.ts rename to apps/server/src/infra/database/index.ts diff --git a/apps/server/src/shared/infra/database/management/database-management.module.spec.ts b/apps/server/src/infra/database/management/database-management.module.spec.ts similarity index 90% rename from apps/server/src/shared/infra/database/management/database-management.module.spec.ts rename to apps/server/src/infra/database/management/database-management.module.spec.ts index 2ea05d7a121..1f80c58b5d4 100644 --- a/apps/server/src/shared/infra/database/management/database-management.module.spec.ts +++ b/apps/server/src/infra/database/management/database-management.module.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { DatabaseManagementModule } from './database-management.module'; import { DatabaseManagementService } from './database-management.service'; diff --git a/apps/server/src/shared/infra/database/management/database-management.module.ts b/apps/server/src/infra/database/management/database-management.module.ts similarity index 100% rename from apps/server/src/shared/infra/database/management/database-management.module.ts rename to apps/server/src/infra/database/management/database-management.module.ts diff --git a/apps/server/src/shared/infra/database/management/database-management.service.spec.ts b/apps/server/src/infra/database/management/database-management.service.spec.ts similarity index 97% rename from apps/server/src/shared/infra/database/management/database-management.service.spec.ts rename to apps/server/src/infra/database/management/database-management.service.spec.ts index 2487fda1280..7bbe7bdc5e3 100644 --- a/apps/server/src/shared/infra/database/management/database-management.service.spec.ts +++ b/apps/server/src/infra/database/management/database-management.service.spec.ts @@ -1,6 +1,6 @@ import { MikroORM } from '@mikro-orm/core'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ObjectId } from 'mongodb'; import { DatabaseManagementService } from './database-management.service'; diff --git a/apps/server/src/shared/infra/database/management/database-management.service.ts b/apps/server/src/infra/database/management/database-management.service.ts similarity index 100% rename from apps/server/src/shared/infra/database/management/database-management.service.ts rename to apps/server/src/infra/database/management/database-management.service.ts diff --git a/apps/server/src/shared/infra/database/management/index.ts b/apps/server/src/infra/database/management/index.ts similarity index 100% rename from apps/server/src/shared/infra/database/management/index.ts rename to apps/server/src/infra/database/management/index.ts diff --git a/apps/server/src/shared/infra/database/mongo-memory-database/index.ts b/apps/server/src/infra/database/mongo-memory-database/index.ts similarity index 100% rename from apps/server/src/shared/infra/database/mongo-memory-database/index.ts rename to apps/server/src/infra/database/mongo-memory-database/index.ts diff --git a/apps/server/src/shared/infra/database/mongo-memory-database/mongo-memory-database.module.ts b/apps/server/src/infra/database/mongo-memory-database/mongo-memory-database.module.ts similarity index 100% rename from apps/server/src/shared/infra/database/mongo-memory-database/mongo-memory-database.module.ts rename to apps/server/src/infra/database/mongo-memory-database/mongo-memory-database.module.ts diff --git a/apps/server/src/shared/infra/database/mongo-memory-database/types.ts b/apps/server/src/infra/database/mongo-memory-database/types.ts similarity index 100% rename from apps/server/src/shared/infra/database/mongo-memory-database/types.ts rename to apps/server/src/infra/database/mongo-memory-database/types.ts diff --git a/apps/server/src/shared/infra/encryption/encryption.interface.ts b/apps/server/src/infra/encryption/encryption.interface.ts similarity index 100% rename from apps/server/src/shared/infra/encryption/encryption.interface.ts rename to apps/server/src/infra/encryption/encryption.interface.ts diff --git a/apps/server/src/shared/infra/encryption/encryption.module.spec.ts b/apps/server/src/infra/encryption/encryption.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/encryption/encryption.module.spec.ts rename to apps/server/src/infra/encryption/encryption.module.spec.ts diff --git a/apps/server/src/shared/infra/encryption/encryption.module.ts b/apps/server/src/infra/encryption/encryption.module.ts similarity index 100% rename from apps/server/src/shared/infra/encryption/encryption.module.ts rename to apps/server/src/infra/encryption/encryption.module.ts diff --git a/apps/server/src/shared/infra/encryption/encryption.service.spec.ts b/apps/server/src/infra/encryption/encryption.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/encryption/encryption.service.spec.ts rename to apps/server/src/infra/encryption/encryption.service.spec.ts diff --git a/apps/server/src/shared/infra/encryption/encryption.service.ts b/apps/server/src/infra/encryption/encryption.service.ts similarity index 100% rename from apps/server/src/shared/infra/encryption/encryption.service.ts rename to apps/server/src/infra/encryption/encryption.service.ts diff --git a/apps/server/src/shared/infra/encryption/index.ts b/apps/server/src/infra/encryption/index.ts similarity index 97% rename from apps/server/src/shared/infra/encryption/index.ts rename to apps/server/src/infra/encryption/index.ts index 201f12060e4..39019741bd0 100644 --- a/apps/server/src/shared/infra/encryption/index.ts +++ b/apps/server/src/infra/encryption/index.ts @@ -1,3 +1,3 @@ -export * from './encryption.module'; -export * from './encryption.interface'; -export * from './encryption.service'; +export * from './encryption.module'; +export * from './encryption.interface'; +export * from './encryption.service'; diff --git a/apps/server/src/shared/infra/feathers/feathers-service.provider.spec.ts b/apps/server/src/infra/feathers/feathers-service.provider.spec.ts similarity index 100% rename from apps/server/src/shared/infra/feathers/feathers-service.provider.spec.ts rename to apps/server/src/infra/feathers/feathers-service.provider.spec.ts diff --git a/apps/server/src/shared/infra/feathers/feathers-service.provider.ts b/apps/server/src/infra/feathers/feathers-service.provider.ts similarity index 100% rename from apps/server/src/shared/infra/feathers/feathers-service.provider.ts rename to apps/server/src/infra/feathers/feathers-service.provider.ts diff --git a/apps/server/src/shared/infra/feathers/feathers.module.ts b/apps/server/src/infra/feathers/feathers.module.ts similarity index 100% rename from apps/server/src/shared/infra/feathers/feathers.module.ts rename to apps/server/src/infra/feathers/feathers.module.ts diff --git a/apps/server/src/shared/infra/feathers/index.ts b/apps/server/src/infra/feathers/index.ts similarity index 100% rename from apps/server/src/shared/infra/feathers/index.ts rename to apps/server/src/infra/feathers/index.ts diff --git a/apps/server/src/shared/infra/file-system/file-system.adapter.spec.ts b/apps/server/src/infra/file-system/file-system.adapter.spec.ts similarity index 100% rename from apps/server/src/shared/infra/file-system/file-system.adapter.spec.ts rename to apps/server/src/infra/file-system/file-system.adapter.spec.ts diff --git a/apps/server/src/shared/infra/file-system/file-system.adapter.ts b/apps/server/src/infra/file-system/file-system.adapter.ts similarity index 100% rename from apps/server/src/shared/infra/file-system/file-system.adapter.ts rename to apps/server/src/infra/file-system/file-system.adapter.ts diff --git a/apps/server/src/shared/infra/file-system/file-system.module.spec.ts b/apps/server/src/infra/file-system/file-system.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/file-system/file-system.module.spec.ts rename to apps/server/src/infra/file-system/file-system.module.spec.ts diff --git a/apps/server/src/shared/infra/file-system/file-system.module.ts b/apps/server/src/infra/file-system/file-system.module.ts similarity index 100% rename from apps/server/src/shared/infra/file-system/file-system.module.ts rename to apps/server/src/infra/file-system/file-system.module.ts diff --git a/apps/server/src/shared/infra/file-system/index.ts b/apps/server/src/infra/file-system/index.ts similarity index 100% rename from apps/server/src/shared/infra/file-system/index.ts rename to apps/server/src/infra/file-system/index.ts diff --git a/apps/server/src/shared/infra/file-system/utf-8-test-file.txt b/apps/server/src/infra/file-system/utf-8-test-file.txt similarity index 100% rename from apps/server/src/shared/infra/file-system/utf-8-test-file.txt rename to apps/server/src/infra/file-system/utf-8-test-file.txt diff --git a/apps/server/src/shared/infra/identity-management/identity-management-oauth.service.ts b/apps/server/src/infra/identity-management/identity-management-oauth.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/identity-management-oauth.service.ts rename to apps/server/src/infra/identity-management/identity-management-oauth.service.ts diff --git a/apps/server/src/shared/infra/identity-management/identity-management.config.ts b/apps/server/src/infra/identity-management/identity-management.config.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/identity-management.config.ts rename to apps/server/src/infra/identity-management/identity-management.config.ts diff --git a/apps/server/src/shared/infra/identity-management/identity-management.module.spec.ts b/apps/server/src/infra/identity-management/identity-management.module.spec.ts similarity index 92% rename from apps/server/src/shared/infra/identity-management/identity-management.module.spec.ts rename to apps/server/src/infra/identity-management/identity-management.module.spec.ts index 9bbfe6d1f93..e81186d7562 100644 --- a/apps/server/src/shared/infra/identity-management/identity-management.module.spec.ts +++ b/apps/server/src/infra/identity-management/identity-management.module.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ConfigModule } from '@nestjs/config'; import { IdentityManagementService } from './identity-management.service'; import { IdentityManagementModule } from './identity-management.module'; diff --git a/apps/server/src/shared/infra/identity-management/identity-management.module.ts b/apps/server/src/infra/identity-management/identity-management.module.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/identity-management.module.ts rename to apps/server/src/infra/identity-management/identity-management.module.ts diff --git a/apps/server/src/shared/infra/identity-management/identity-management.service.ts b/apps/server/src/infra/identity-management/identity-management.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/identity-management.service.ts rename to apps/server/src/infra/identity-management/identity-management.service.ts diff --git a/apps/server/src/shared/infra/identity-management/index.ts b/apps/server/src/infra/identity-management/index.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/index.ts rename to apps/server/src/infra/identity-management/index.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts b/apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts rename to apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-administration.module.spec.ts b/apps/server/src/infra/identity-management/keycloak-administration/keycloak-administration.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-administration.module.spec.ts rename to apps/server/src/infra/identity-management/keycloak-administration/keycloak-administration.module.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-administration.module.ts b/apps/server/src/infra/identity-management/keycloak-administration/keycloak-administration.module.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-administration.module.ts rename to apps/server/src/infra/identity-management/keycloak-administration/keycloak-administration.module.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-config.ts b/apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/keycloak-config.ts rename to apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts rename to apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts similarity index 98% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts index 16c5c9c2d1a..9b3120b8b8c 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { LegacyLogger } from '@src/core/logger'; import { KeycloakConfigurationUc } from '../uc/keycloak-configuration.uc'; import { KeycloakConsole } from './keycloak-configuration.console'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts b/apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts similarity index 98% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts index 1d597e7020a..85d3f7a5a3c 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts @@ -1,4 +1,4 @@ -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { LegacyLogger } from '@src/core/logger'; import { Command, CommandOption, Console } from 'nestjs-console'; import { KeycloakConfigurationUc } from '../uc/keycloak-configuration.uc'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.ts b/apps/server/src/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/controller/keycloak-configuration.controller.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/json-account.interface.ts b/apps/server/src/infra/identity-management/keycloak-configuration/interface/json-account.interface.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/json-account.interface.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/interface/json-account.interface.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/json-user.interface.ts b/apps/server/src/infra/identity-management/keycloak-configuration/interface/json-user.interface.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/json-user.interface.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/interface/json-user.interface.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/keycloak-configuration-input-files.interface.ts b/apps/server/src/infra/identity-management/keycloak-configuration/interface/keycloak-configuration-input-files.interface.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/interface/keycloak-configuration-input-files.interface.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/interface/keycloak-configuration-input-files.interface.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-config.ts b/apps/server/src/infra/identity-management/keycloak-configuration/keycloak-config.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-config.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/keycloak-config.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts similarity index 94% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts index 3e31e5dcb08..f1d0ee3808e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ConfigModule } from '@nestjs/config'; import { KeycloakConfigurationModule } from './keycloak-configuration.module'; import { KeycloakConsole } from './console/keycloak-configuration.console'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts b/apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts similarity index 93% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts index 2012dad00a5..4e570b30dcf 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/keycloak-configuration.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { EncryptionModule } from '@shared/infra/encryption'; -import { ConsoleWriterModule } from '@shared/infra/console'; +import { EncryptionModule } from '@infra/encryption'; +import { ConsoleWriterModule } from '@infra/console'; import { AccountModule } from '@modules/account'; import { SystemModule } from '@modules/system'; import { KeycloakAdministrationModule } from '../keycloak-administration/keycloak-administration.module'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts similarity index 98% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts index b28d74ca3d5..94ef9c042a4 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import IdentityProviderRepresentation from '@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { OidcConfigDto } from '@modules/system/service'; import { OidcIdentityProviderMapper } from './identity-provider.mapper'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts similarity index 92% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts index 75737263cac..6573ed35a5b 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts @@ -1,6 +1,6 @@ import IdentityProviderRepresentation from '@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation'; import { Inject } from '@nestjs/common'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; import { OidcConfigDto } from '@modules/system/service'; export class OidcIdentityProviderMapper { diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts similarity index 98% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts index ad5af6a1d75..98ed3552918 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts @@ -4,7 +4,7 @@ import AuthenticationExecutionExportRepresentation from '@keycloak/keycloak-admi import AuthenticationFlowRepresentation from '@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { SystemRepo } from '@shared/repo/system/system.repo'; import { systemFactory } from '@shared/testing/factory'; import { LoggerModule } from '@src/core/logger'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts similarity index 99% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts index 1388392995e..61e475fe3e3 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts @@ -10,7 +10,7 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { SymetricKeyEncryptionService } from '@shared/infra/encryption'; +import { SymetricKeyEncryptionService } from '@infra/encryption'; import { systemFactory } from '@shared/testing'; import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; import { SystemOidcService } from '@modules/system/service/system-oidc.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts similarity index 98% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts index baf86c58ccf..eba5e20fcd0 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts @@ -3,7 +3,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { Account } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { accountFactory, cleanupCollections } from '@shared/testing'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts similarity index 97% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts index 87f28a28d76..11858b56a2b 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts @@ -2,7 +2,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; import { faker } from '@faker-js/faker'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.spec.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts b/apps/server/src/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts rename to apps/server/src/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.spec.ts b/apps/server/src/infra/identity-management/keycloak/keycloak.module.spec.ts similarity index 94% rename from apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.spec.ts rename to apps/server/src/infra/identity-management/keycloak/keycloak.module.spec.ts index 6bed42efdd6..282d6dd0e40 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak/keycloak.module.spec.ts @@ -1,6 +1,6 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { KeycloakModule } from './keycloak.module'; import { KeycloakIdentityManagementService } from './service/keycloak-identity-management.service'; import { KeycloakIdentityManagementOauthService } from './service/keycloak-identity-management-oauth.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.ts b/apps/server/src/infra/identity-management/keycloak/keycloak.module.ts similarity index 92% rename from apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.ts rename to apps/server/src/infra/identity-management/keycloak/keycloak.module.ts index 4f7407f80c9..c2fd27be29e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/keycloak.module.ts +++ b/apps/server/src/infra/identity-management/keycloak/keycloak.module.ts @@ -1,6 +1,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { EncryptionModule } from '@shared/infra/encryption'; +import { EncryptionModule } from '@infra/encryption'; import { LoggerModule } from '@src/core/logger'; import { KeycloakAdministrationModule } from '../keycloak-administration/keycloak-administration.module'; import { KeycloakIdentityManagementOauthService } from './service/keycloak-identity-management-oauth.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts similarity index 96% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts index 9e4cf5567c1..40456bbb184 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.integration.spec.ts @@ -1,7 +1,7 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { KeycloakModule } from '@shared/infra/identity-management/keycloak/keycloak.module'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { KeycloakModule } from '@infra/identity-management/keycloak/keycloak.module'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; import { KeycloakAdministrationModule } from '../../keycloak-administration/keycloak-administration.module'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts similarity index 99% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts index f9c3745ce64..9ccd20b6f99 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts @@ -3,7 +3,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts similarity index 97% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts index 7e10179b2cd..9eab3a4f60a 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts @@ -1,7 +1,7 @@ import { HttpService } from '@nestjs/axios'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; import { OauthConfigDto } from '@modules/system/service'; import qs from 'qs'; import { lastValueFrom } from 'rxjs'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts similarity index 96% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts index fd66603f730..c5f83c35f17 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts @@ -4,8 +4,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpModule } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount, IdmAccountUpdate } from '@shared/domain'; -import { KeycloakAdministrationService } from '@shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service'; -import { KeycloakModule } from '@shared/infra/identity-management/keycloak/keycloak.module'; +import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; +import { KeycloakModule } from '@infra/identity-management/keycloak/keycloak.module'; import { ServerModule } from '@modules/server'; import { v1 } from 'uuid'; import { IdentityManagementService } from '../../identity-management.service'; diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts similarity index 100% rename from apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts rename to apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts diff --git a/apps/server/src/shared/infra/index.ts b/apps/server/src/infra/index.ts similarity index 100% rename from apps/server/src/shared/infra/index.ts rename to apps/server/src/infra/index.ts diff --git a/apps/server/src/shared/infra/mail/index.ts b/apps/server/src/infra/mail/index.ts similarity index 100% rename from apps/server/src/shared/infra/mail/index.ts rename to apps/server/src/infra/mail/index.ts diff --git a/apps/server/src/infra/mail/interfaces/mail-config.ts b/apps/server/src/infra/mail/interfaces/mail-config.ts new file mode 100644 index 00000000000..6dbb0c7864d --- /dev/null +++ b/apps/server/src/infra/mail/interfaces/mail-config.ts @@ -0,0 +1,3 @@ +export interface IMailConfig { + ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: string[]; +} diff --git a/apps/server/src/shared/infra/mail/mail.interface.ts b/apps/server/src/infra/mail/mail.interface.ts similarity index 100% rename from apps/server/src/shared/infra/mail/mail.interface.ts rename to apps/server/src/infra/mail/mail.interface.ts diff --git a/apps/server/src/shared/infra/mail/mail.module.spec.ts b/apps/server/src/infra/mail/mail.module.spec.ts similarity index 90% rename from apps/server/src/shared/infra/mail/mail.module.spec.ts rename to apps/server/src/infra/mail/mail.module.spec.ts index 09cd1b6a9cc..3514b0e1043 100644 --- a/apps/server/src/shared/infra/mail/mail.module.spec.ts +++ b/apps/server/src/infra/mail/mail.module.spec.ts @@ -1,6 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { Test, TestingModule } from '@nestjs/testing'; -import { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq/rabbitmq.module'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { MailModule } from './mail.module'; import { MailService } from './mail.service'; diff --git a/apps/server/src/shared/infra/mail/mail.module.ts b/apps/server/src/infra/mail/mail.module.ts similarity index 78% rename from apps/server/src/shared/infra/mail/mail.module.ts rename to apps/server/src/infra/mail/mail.module.ts index 1ca0630c44f..ee6d50d59e7 100644 --- a/apps/server/src/shared/infra/mail/mail.module.ts +++ b/apps/server/src/infra/mail/mail.module.ts @@ -1,5 +1,7 @@ import { Module, DynamicModule } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { MailService } from './mail.service'; +import { IMailConfig } from './interfaces/mail-config'; interface MailModuleOptions { exchange: string; @@ -17,6 +19,7 @@ export class MailModule { provide: 'MAIL_SERVICE_OPTIONS', useValue: { exchange: options.exchange, routingKey: options.routingKey }, }, + ConfigService, ], exports: [MailService], }; diff --git a/apps/server/src/infra/mail/mail.service.spec.ts b/apps/server/src/infra/mail/mail.service.spec.ts new file mode 100644 index 00000000000..ebc77030252 --- /dev/null +++ b/apps/server/src/infra/mail/mail.service.spec.ts @@ -0,0 +1,83 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Mail } from './mail.interface'; +import { MailService } from './mail.service'; +import { IMailConfig } from './interfaces/mail-config'; + +describe('MailService', () => { + let module: TestingModule; + let service: MailService; + let amqpConnection: AmqpConnection; + + const mailServiceOptions = { + exchange: 'exchange', + routingKey: 'routingKey', + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MailService, + { provide: AmqpConnection, useValue: { publish: () => {} } }, + { provide: 'MAIL_SERVICE_OPTIONS', useValue: mailServiceOptions }, + { + provide: ConfigService, + useValue: createMock>({ get: () => ['schul-cloud.org', 'example.com'] }), + }, + ], + }).compile(); + + service = module.get(MailService); + amqpConnection = module.get(AmqpConnection); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('send', () => { + describe('when recipients array is empty', () => { + it('should not send email', async () => { + const data: Mail = { + mail: { plainTextContent: 'content', subject: 'Test' }, + recipients: ['test@schul-cloud.org'], + }; + + const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); + + await service.send(data); + + expect(amqpConnectionSpy).toHaveBeenCalledTimes(0); + }); + }); + describe('when sending email', () => { + it('should remove email address that have blacklisted domain and send given data to queue', async () => { + const data: Mail = { + mail: { plainTextContent: 'content', subject: 'Test' }, + recipients: ['test@schul-cloud.org', 'test@example1.com', 'test2@schul-cloud.org', 'test3@schul-cloud.org'], + cc: ['test@example.com', 'test5@schul-cloud.org', 'test6@schul-cloud.org'], + bcc: ['test7@schul-cloud.org', 'test@example2.com', 'test8@schul-cloud.org'], + replyTo: ['test@example3.com', 'test9@schul-cloud.org', 'test10@schul-cloud.org'], + }; + + const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); + + await service.send(data); + + expect(data.recipients).toEqual(['test@example1.com']); + expect(data.cc).toEqual([]); + expect(data.bcc).toEqual(['test@example2.com']); + expect(data.replyTo).toEqual(['test@example3.com']); + + const expectedParams = [mailServiceOptions.exchange, mailServiceOptions.routingKey, data, { persistent: true }]; + expect(amqpConnectionSpy).toHaveBeenCalledWith(...expectedParams); + }); + }); + }); +}); diff --git a/apps/server/src/infra/mail/mail.service.ts b/apps/server/src/infra/mail/mail.service.ts new file mode 100644 index 00000000000..432f0746934 --- /dev/null +++ b/apps/server/src/infra/mail/mail.service.ts @@ -0,0 +1,57 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Mail } from './mail.interface'; +import { IMailConfig } from './interfaces/mail-config'; + +interface MailServiceOptions { + exchange: string; + routingKey: string; +} + +@Injectable() +export class MailService { + private readonly domainBlacklist: string[]; + + constructor( + private readonly amqpConnection: AmqpConnection, + @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions, + private readonly configService: ConfigService + ) { + this.domainBlacklist = this.configService.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS'); + } + + public async send(data: Mail): Promise { + if (this.domainBlacklist.length > 0) { + data.recipients = this.filterEmailAdresses(data.recipients) as string[]; + data.cc = this.filterEmailAdresses(data.cc); + data.bcc = this.filterEmailAdresses(data.bcc); + data.replyTo = this.filterEmailAdresses(data.replyTo); + } + + if (data.recipients.length === 0) { + return; + } + + await this.amqpConnection.publish(this.options.exchange, this.options.routingKey, data, { persistent: true }); + } + + private filterEmailAdresses(mails: string[] | undefined): string[] | undefined { + if (mails === undefined || mails === null) { + return mails; + } + const mailWhitelist: string[] = []; + + for (const mail of mails) { + const mailDomain = this.getMailDomain(mail); + if (mailDomain && !this.domainBlacklist.includes(mailDomain)) { + mailWhitelist.push(mail); + } + } + return mailWhitelist; + } + + private getMailDomain(mail: string): string { + return mail.split('@')[1]; + } +} diff --git a/apps/server/src/shared/infra/metrics/index.ts b/apps/server/src/infra/metrics/index.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/index.ts rename to apps/server/src/infra/metrics/index.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/app.spec.ts b/apps/server/src/infra/metrics/prometheus/app.spec.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/app.spec.ts rename to apps/server/src/infra/metrics/prometheus/app.spec.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/app.ts b/apps/server/src/infra/metrics/prometheus/app.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/app.ts rename to apps/server/src/infra/metrics/prometheus/app.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/config.spec.ts b/apps/server/src/infra/metrics/prometheus/config.spec.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/config.spec.ts rename to apps/server/src/infra/metrics/prometheus/config.spec.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/config.ts b/apps/server/src/infra/metrics/prometheus/config.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/config.ts rename to apps/server/src/infra/metrics/prometheus/config.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/handler.spec.ts b/apps/server/src/infra/metrics/prometheus/handler.spec.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/handler.spec.ts rename to apps/server/src/infra/metrics/prometheus/handler.spec.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/handler.ts b/apps/server/src/infra/metrics/prometheus/handler.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/handler.ts rename to apps/server/src/infra/metrics/prometheus/handler.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/index.ts b/apps/server/src/infra/metrics/prometheus/index.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/index.ts rename to apps/server/src/infra/metrics/prometheus/index.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/middleware.spec.ts b/apps/server/src/infra/metrics/prometheus/middleware.spec.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/middleware.spec.ts rename to apps/server/src/infra/metrics/prometheus/middleware.spec.ts diff --git a/apps/server/src/shared/infra/metrics/prometheus/middleware.ts b/apps/server/src/infra/metrics/prometheus/middleware.ts similarity index 100% rename from apps/server/src/shared/infra/metrics/prometheus/middleware.ts rename to apps/server/src/infra/metrics/prometheus/middleware.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/index.ts b/apps/server/src/infra/oauth-provider/dto/index.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/index.ts rename to apps/server/src/infra/oauth-provider/dto/index.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/interface/oauth-client.interface.ts b/apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/interface/oauth-client.interface.ts rename to apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/interface/oidc-context.interface.ts b/apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/interface/oidc-context.interface.ts rename to apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/request/accept-consent-request.body.ts b/apps/server/src/infra/oauth-provider/dto/request/accept-consent-request.body.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/request/accept-consent-request.body.ts rename to apps/server/src/infra/oauth-provider/dto/request/accept-consent-request.body.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/request/accept-login-request.body.ts b/apps/server/src/infra/oauth-provider/dto/request/accept-login-request.body.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/request/accept-login-request.body.ts rename to apps/server/src/infra/oauth-provider/dto/request/accept-login-request.body.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/request/reject-request.body.ts b/apps/server/src/infra/oauth-provider/dto/request/reject-request.body.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/request/reject-request.body.ts rename to apps/server/src/infra/oauth-provider/dto/request/reject-request.body.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/response/consent-session.response.ts b/apps/server/src/infra/oauth-provider/dto/response/consent-session.response.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/response/consent-session.response.ts rename to apps/server/src/infra/oauth-provider/dto/response/consent-session.response.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/response/consent.response.ts b/apps/server/src/infra/oauth-provider/dto/response/consent.response.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/response/consent.response.ts rename to apps/server/src/infra/oauth-provider/dto/response/consent.response.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/response/introspect.response.ts b/apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/response/introspect.response.ts rename to apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/response/login.response.ts b/apps/server/src/infra/oauth-provider/dto/response/login.response.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/response/login.response.ts rename to apps/server/src/infra/oauth-provider/dto/response/login.response.ts diff --git a/apps/server/src/shared/infra/oauth-provider/dto/response/redirect.response.ts b/apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/dto/response/redirect.response.ts rename to apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts diff --git a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts similarity index 69% rename from apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts rename to apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts index 0e244b37d16..2a373195bc6 100644 --- a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts +++ b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts @@ -1,7 +1,5 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { HttpService } from '@nestjs/axios'; -import { Test, TestingModule } from '@nestjs/testing'; import { AcceptConsentRequestBody, AcceptLoginRequestBody, @@ -11,12 +9,16 @@ import { ProviderOauthClient, ProviderRedirectResponse, RejectRequestBody, -} from '@shared/infra/oauth-provider/dto'; -import { ProviderConsentSessionResponse } from '@shared/infra/oauth-provider/dto/response/consent-session.response'; -import { HydraAdapter } from '@shared/infra/oauth-provider/hydra/hydra.adapter'; +} from '@infra/oauth-provider/dto'; +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; -import { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; -import { of } from 'rxjs'; +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; +import { of, throwError } from 'rxjs'; +import { ProviderConsentSessionResponse } from '../dto'; +import { HydraOauthFailedLoggableException } from '../loggable'; +import { HydraAdapter } from './hydra.adapter'; import resetAllMocks = jest.resetAllMocks; class HydraAdapterSpec extends HydraAdapter { @@ -66,100 +68,66 @@ describe('HydraService', () => { }); describe('request', () => { - it('should return data when called with all parameters', async () => { - const data: { test: string } = { - test: 'data', - }; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - const result: { test: string } = await service.requestSpec( - 'GET', - 'testUrl', - { dataKey: 'dataValue' }, - { headerKey: 'headerValue' } - ); + describe('when called with all parameters', () => { + const setup = () => { + const data: { test: string } = { + test: 'data', + }; - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - headerKey: 'headerValue', - }, - data: { dataKey: 'dataValue' }, - }) - ); - }); + httpService.request.mockReturnValue(of(createAxiosResponse(data))); - it('should return data when called with only necessary parameters', async () => { - const data: { test: string } = { - test: 'data', + return { + data, + }; }; - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - const result: { test: string } = await service.requestSpec('GET', 'testUrl'); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('Client Flow', () => { - describe('listOAuth2Clients', () => { - it('should list all oauth2 clients', async () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - }, - { - client_id: 'client2', - }, - ]; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); + it('should return data', async () => { + const { data } = setup(); - const result: ProviderOauthClient[] = await service.listOAuth2Clients(); + const result: { test: string } = await service.requestSpec( + 'GET', + 'testUrl', + { dataKey: 'dataValue' }, + { headerKey: 'headerValue' } + ); expect(result).toEqual(data); expect(httpService.request).toHaveBeenCalledWith( expect.objectContaining({ - url: `${hydraUri}/clients`, + url: 'testUrl', method: 'GET', headers: { 'X-Forwarded-Proto': 'https', + headerKey: 'headerValue', }, + data: { dataKey: 'dataValue' }, }) ); }); + }); - it('should list all oauth2 clients within parameters', async () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - owner: 'clientOwner', - }, - ]; + describe('when called with only necessary parameters', () => { + const setup = () => { + const data: { test: string } = { + test: 'data', + }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); - const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + return { + data, + }; + }; + + it('should return data', async () => { + const { data } = setup(); + + const result: { test: string } = await service.requestSpec('GET', 'testUrl'); expect(result).toEqual(data); expect(httpService.request).toHaveBeenCalledWith( expect.objectContaining({ - url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, + url: 'testUrl', method: 'GET', headers: { 'X-Forwarded-Proto': 'https', @@ -169,13 +137,130 @@ describe('HydraService', () => { }); }); + describe('when error occurs', () => { + describe('when error is an axios error', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build({}); + + httpService.request.mockReturnValueOnce(throwError(() => axiosError)); + + return { + axiosError, + }; + }; + + it('should throw hydra oauth loggable exception', async () => { + const { axiosError } = setup(); + + await expect(service.listOAuth2Clients()).rejects.toThrow(new HydraOauthFailedLoggableException(axiosError)); + }); + }); + + describe('when error is any other error', () => { + const setup = () => { + httpService.request.mockReturnValueOnce(throwError(() => new Error('unknown error'))); + }; + + it('should throw the error', async () => { + setup(); + + await expect(service.listOAuth2Clients()).rejects.toThrow(new Error('unknown error')); + }); + }); + }); + }); + + describe('Client Flow', () => { + describe('listOAuth2Clients', () => { + describe('when only clientIds are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = [ + { + client_id: 'client1', + }, + { + client_id: 'client2', + }, + ]; + + httpService.request.mockReturnValue(of(createAxiosResponse(data))); + + return { + data, + }; + }; + + it('should list all oauth2 clients', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(); + + expect(result).toEqual(data); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + + describe('when clientId and other parameters are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = [ + { + client_id: 'client1', + owner: 'clientOwner', + }, + ]; + + httpService.request.mockReturnValue(of(createAxiosResponse(data))); + + return { + data, + }; + }; + + it('should list all oauth2 clients within parameters', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + + expect(result).toEqual(data); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + }); + describe('getOAuth2Client', () => { - it('should get oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should get oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.getOAuth2Client('clientId'); expect(result).toEqual(data); @@ -192,12 +277,20 @@ describe('HydraService', () => { }); describe('createOAuth2Client', () => { - it('should create oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should create oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.createOAuth2Client(data); expect(result).toEqual(data); @@ -215,12 +308,20 @@ describe('HydraService', () => { }); describe('updateOAuth2Client', () => { - it('should update oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should update oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.updateOAuth2Client('clientId', data); expect(result).toEqual(data); @@ -238,8 +339,12 @@ describe('HydraService', () => { }); describe('deleteOAuth2Client', () => { - it('should delete oauth2 client', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse({}))); + }; + + it('should delete oauth2 client', async () => { + setup(); await service.deleteOAuth2Client('clientId'); @@ -268,26 +373,30 @@ describe('HydraService', () => { }); describe('getConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const config: AxiosRequestConfig = { method: 'GET', url: `${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`, }; httpService.request.mockReturnValue(of(createAxiosResponse({ challenge }))); - // Act + return { + config, + }; + }; + + it('should make http request', async () => { + const { config } = setup(); + const result: ProviderConsentResponse = await service.getConsentRequest(challenge); - // Assert expect(result.challenge).toEqual(challenge); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('acceptConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const body: AcceptConsentRequestBody = { grant_scope: ['offline', 'openid'], }; @@ -301,18 +410,25 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should make http request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.acceptConsentRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('rejectConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const body: RejectRequestBody = { error: 'error', }; @@ -326,20 +442,36 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should make http request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.rejectConsentRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('listConsentSessions', () => { - it('should list all consent sessions', async () => { + const setup = () => { const response: ProviderConsentSessionResponse[] = [{ consent_request: { challenge: 'challenge' } }]; httpService.request.mockReturnValue(of(createAxiosResponse(response))); + return { + response, + }; + }; + + it('should list all consent sessions', async () => { + const { response } = setup(); + const result: ProviderConsentSessionResponse[] = await service.listConsentSessions('userId'); expect(result).toEqual(response); @@ -356,8 +488,12 @@ describe('HydraService', () => { }); describe('revokeConsentSession', () => { - it('should revoke all consent sessions', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse({}))); + }; + + it('should revoke all consent sessions', async () => { + setup(); await service.revokeConsentSession('userId', 'clientId'); @@ -375,7 +511,7 @@ describe('HydraService', () => { describe('Logout Flow', () => { describe('acceptLogoutRequest', () => { - it('should make http request', async () => { + const setup = () => { const responseMock: ProviderRedirectResponse = { redirect_to: 'redirect_mock' }; httpService.request.mockReturnValue(of(createAxiosResponse(responseMock))); const config: AxiosRequestConfig = { @@ -384,6 +520,15 @@ describe('HydraService', () => { headers: { 'X-Forwarded-Proto': 'https' }, }; + return { + responseMock, + config, + }; + }; + + it('should make http request', async () => { + const { responseMock, config } = setup(); + const response: ProviderRedirectResponse = await service.acceptLogoutRequest('challenge_mock'); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); @@ -394,12 +539,20 @@ describe('HydraService', () => { describe('Miscellaneous', () => { describe('introspectOAuth2Token', () => { - it('should return introspect', async () => { + const setup = () => { const response: IntrospectResponse = { active: true, }; httpService.request.mockReturnValue(of(createAxiosResponse(response))); + return { + response, + }; + }; + + it('should return introspect', async () => { + const { response } = setup(); + const result: IntrospectResponse = await service.introspectOAuth2Token('token', 'scope'); expect(result).toEqual(response); @@ -418,8 +571,12 @@ describe('HydraService', () => { }); describe('isInstanceAlive', () => { - it('should check if hydra is alive', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse(true))); + }; + + it('should check if hydra is alive', async () => { + setup(); const result: boolean = await service.isInstanceAlive(); @@ -459,25 +616,30 @@ describe('HydraService', () => { }); describe('getLoginRequest', () => { - it('should send login request', async () => { - // Arrange + const setup = () => { const requestConfig: AxiosRequestConfig = { method: 'GET', url: `${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`, }; httpService.request.mockReturnValue(of(createAxiosResponse(providerLoginResponse))); - // Act + return { + requestConfig, + }; + }; + + it('should send login request', async () => { + const { requestConfig } = setup(); + const response: ProviderLoginResponse = await service.getLoginRequest(challenge); - // Assert expect(response).toEqual(providerLoginResponse); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(requestConfig)); }); }); describe('acceptLoginRequest', () => { - it('should send accept login request', async () => { + const setup = () => { const body: AcceptLoginRequestBody = { subject: '', force_subject_identifier: '', @@ -494,6 +656,16 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should send accept login request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.acceptLoginRequest(challenge, body); expect(result.redirect_to).toEqual(expectedRedirectTo); @@ -502,8 +674,7 @@ describe('HydraService', () => { }); describe('rejectLoginRequest', () => { - it('should send reject login request', async () => { - // Arrange + const setup = () => { const body: RejectRequestBody = { error: 'error', }; @@ -517,10 +688,18 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should send reject login request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.rejectLoginRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); diff --git a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts similarity index 89% rename from apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts rename to apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts index f554a15abd3..3e2d389d643 100644 --- a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts +++ b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts @@ -1,21 +1,23 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; -import { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; +import { AxiosResponse, isAxiosError, Method, RawAxiosRequestHeaders } from 'axios'; import QueryString from 'qs'; -import { Observable, firstValueFrom } from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { URL } from 'url'; import { AcceptConsentRequestBody, AcceptLoginRequestBody, IntrospectResponse, ProviderConsentResponse, + ProviderConsentSessionResponse, ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, RejectRequestBody, } from '../dto'; -import { ProviderConsentSessionResponse } from '../dto/response/consent-session.response'; +import { HydraOauthFailedLoggableException } from '../loggable'; import { OauthProviderService } from '../oauth-provider.service'; @Injectable() @@ -160,15 +162,26 @@ export class HydraAdapter extends OauthProviderService { data?: unknown, additionalHeaders: RawAxiosRequestHeaders = {} ): Promise { - const observable: Observable> = this.httpService.request({ - url, - method, - headers: { - 'X-Forwarded-Proto': 'https', - ...additionalHeaders, - }, - data, - }); + const observable: Observable> = this.httpService + .request({ + url, + method, + headers: { + 'X-Forwarded-Proto': 'https', + ...additionalHeaders, + }, + data, + }) + .pipe( + catchError((error: unknown) => { + if (isAxiosError(error)) { + throw new HydraOauthFailedLoggableException(error); + } else { + throw error; + } + }) + ); + const response: AxiosResponse = await firstValueFrom(observable); return response.data; } diff --git a/apps/server/src/shared/infra/oauth-provider/index.ts b/apps/server/src/infra/oauth-provider/index.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/index.ts rename to apps/server/src/infra/oauth-provider/index.ts diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts new file mode 100644 index 00000000000..a78b365d126 --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { HydraOauthFailedLoggableException } from './hydra-oauth-failed-loggable-exception'; + +describe(HydraOauthFailedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build({ stack: 'someStack' }); + + const exception = new HydraOauthFailedLoggableException(axiosError); + + return { + exception, + axiosError, + error, + }; + }; + + it('should return the correct log message', () => { + const { exception, axiosError, error } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'HYDRA_OAUTH_FAILED', + message: axiosError.message, + stack: axiosError.stack, + data: JSON.stringify(error), + }); + }); + }); +}); diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts new file mode 100644 index 00000000000..c92dd3c7fff --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts @@ -0,0 +1,8 @@ +import { AxiosErrorLoggable } from '@src/core/error/loggable'; +import { AxiosError } from 'axios'; + +export class HydraOauthFailedLoggableException extends AxiosErrorLoggable { + constructor(error: AxiosError) { + super(error, 'HYDRA_OAUTH_FAILED'); + } +} diff --git a/apps/server/src/infra/oauth-provider/loggable/index.ts b/apps/server/src/infra/oauth-provider/loggable/index.ts new file mode 100644 index 00000000000..677fe4f84e6 --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/index.ts @@ -0,0 +1 @@ +export * from './hydra-oauth-failed-loggable-exception'; diff --git a/apps/server/src/shared/infra/oauth-provider/oauth-provider-service.module.ts b/apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts similarity index 61% rename from apps/server/src/shared/infra/oauth-provider/oauth-provider-service.module.ts rename to apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts index 646ad228245..521f9216050 100644 --- a/apps/server/src/shared/infra/oauth-provider/oauth-provider-service.module.ts +++ b/apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; -import { OauthProviderService } from '@shared/infra/oauth-provider/oauth-provider.service'; -import { HydraAdapter } from '@shared/infra/oauth-provider/hydra/hydra.adapter'; import { HttpModule } from '@nestjs/axios'; +import { OauthProviderService } from './oauth-provider.service'; +import { HydraAdapter } from './hydra/hydra.adapter'; @Module({ imports: [HttpModule], diff --git a/apps/server/src/shared/infra/oauth-provider/oauth-provider.service.ts b/apps/server/src/infra/oauth-provider/oauth-provider.service.ts similarity index 100% rename from apps/server/src/shared/infra/oauth-provider/oauth-provider.service.ts rename to apps/server/src/infra/oauth-provider/oauth-provider.service.ts diff --git a/apps/server/src/shared/infra/preview-generator/index.ts b/apps/server/src/infra/preview-generator/index.ts similarity index 97% rename from apps/server/src/shared/infra/preview-generator/index.ts rename to apps/server/src/infra/preview-generator/index.ts index 570b38cc9bd..c5d413789be 100644 --- a/apps/server/src/shared/infra/preview-generator/index.ts +++ b/apps/server/src/infra/preview-generator/index.ts @@ -1,4 +1,4 @@ -export * from './interface'; -export * from './preview-generator-consumer.module'; -export * from './preview-generator-producer.module'; -export * from './preview.producer'; +export * from './interface'; +export * from './preview-generator-consumer.module'; +export * from './preview-generator-producer.module'; +export * from './preview.producer'; diff --git a/apps/server/src/shared/infra/preview-generator/interface/index.ts b/apps/server/src/infra/preview-generator/interface/index.ts similarity index 96% rename from apps/server/src/shared/infra/preview-generator/interface/index.ts rename to apps/server/src/infra/preview-generator/interface/index.ts index 37aae418ee2..45799160cd5 100644 --- a/apps/server/src/shared/infra/preview-generator/interface/index.ts +++ b/apps/server/src/infra/preview-generator/interface/index.ts @@ -1 +1 @@ -export * from './preview'; +export * from './preview'; diff --git a/apps/server/src/shared/infra/preview-generator/interface/preview-consumer-config.ts b/apps/server/src/infra/preview-generator/interface/preview-consumer-config.ts similarity index 79% rename from apps/server/src/shared/infra/preview-generator/interface/preview-consumer-config.ts rename to apps/server/src/infra/preview-generator/interface/preview-consumer-config.ts index 2924fc945bc..3e08b89075c 100644 --- a/apps/server/src/shared/infra/preview-generator/interface/preview-consumer-config.ts +++ b/apps/server/src/infra/preview-generator/interface/preview-consumer-config.ts @@ -1,4 +1,4 @@ -import { S3Config } from '@shared/infra/s3-client'; +import { S3Config } from '@infra/s3-client'; export interface PreviewModuleConfig { NEST_LOG_LEVEL: string; diff --git a/apps/server/src/shared/infra/preview-generator/interface/preview.ts b/apps/server/src/infra/preview-generator/interface/preview.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/interface/preview.ts rename to apps/server/src/infra/preview-generator/interface/preview.ts diff --git a/apps/server/src/shared/infra/preview-generator/loggable/preview-actions.loggable.spec.ts b/apps/server/src/infra/preview-generator/loggable/preview-actions.loggable.spec.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/loggable/preview-actions.loggable.spec.ts rename to apps/server/src/infra/preview-generator/loggable/preview-actions.loggable.spec.ts diff --git a/apps/server/src/shared/infra/preview-generator/loggable/preview-actions.loggable.ts b/apps/server/src/infra/preview-generator/loggable/preview-actions.loggable.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/loggable/preview-actions.loggable.ts rename to apps/server/src/infra/preview-generator/loggable/preview-actions.loggable.ts diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator-consumer.module.ts b/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts similarity index 89% rename from apps/server/src/shared/infra/preview-generator/preview-generator-consumer.module.ts rename to apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts index 9d352b81d9d..ca4df0d074c 100644 --- a/apps/server/src/shared/infra/preview-generator/preview-generator-consumer.module.ts +++ b/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts @@ -1,7 +1,7 @@ import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; -import { S3ClientAdapter, S3ClientModule } from '@shared/infra/s3-client'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { S3ClientAdapter, S3ClientModule } from '@infra/s3-client'; import { createConfigModuleOptions } from '@src/config'; import { Logger, LoggerModule } from '@src/core/logger'; import { PreviewConfig } from './interface/preview-consumer-config'; diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator-producer.module.ts b/apps/server/src/infra/preview-generator/preview-generator-producer.module.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/preview-generator-producer.module.ts rename to apps/server/src/infra/preview-generator/preview-generator-producer.module.ts diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.builder.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.builder.spec.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/preview-generator.builder.spec.ts rename to apps/server/src/infra/preview-generator/preview-generator.builder.spec.ts diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.builder.ts b/apps/server/src/infra/preview-generator/preview-generator.builder.ts similarity index 87% rename from apps/server/src/shared/infra/preview-generator/preview-generator.builder.ts rename to apps/server/src/infra/preview-generator/preview-generator.builder.ts index 4c5561ed089..088f9e7ab08 100644 --- a/apps/server/src/shared/infra/preview-generator/preview-generator.builder.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.builder.ts @@ -1,4 +1,4 @@ -import { File } from '@shared/infra/s3-client'; +import { File } from '@infra/s3-client'; import { PassThrough } from 'stream'; import { PreviewOptions } from './interface'; diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.consumer.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.consumer.spec.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/preview-generator.consumer.spec.ts rename to apps/server/src/infra/preview-generator/preview-generator.consumer.spec.ts diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.consumer.ts b/apps/server/src/infra/preview-generator/preview-generator.consumer.ts similarity index 92% rename from apps/server/src/shared/infra/preview-generator/preview-generator.consumer.ts rename to apps/server/src/infra/preview-generator/preview-generator.consumer.ts index 8fc08d261f3..d34fc8bc37c 100644 --- a/apps/server/src/shared/infra/preview-generator/preview-generator.consumer.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.consumer.ts @@ -1,7 +1,7 @@ import { RabbitPayload, RabbitRPC } from '@golevelup/nestjs-rabbitmq'; import { Injectable } from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { FilesPreviewEvents, FilesPreviewExchange } from '@src/shared/infra/rabbitmq'; +import { FilesPreviewEvents, FilesPreviewExchange } from '@infra/rabbitmq'; import { PreviewFileOptions } from './interface'; import { PreviewActionsLoggable } from './loggable/preview-actions.loggable'; import { PreviewGeneratorService } from './preview-generator.service'; diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.service.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts similarity index 98% rename from apps/server/src/shared/infra/preview-generator/preview-generator.service.spec.ts rename to apps/server/src/infra/preview-generator/preview-generator.service.spec.ts index b8eeea612f5..016c261b122 100644 --- a/apps/server/src/shared/infra/preview-generator/preview-generator.service.spec.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts @@ -1,6 +1,6 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; +import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { Logger } from '@src/core/logger'; import { Readable } from 'node:stream'; import { PreviewGeneratorService } from './preview-generator.service'; diff --git a/apps/server/src/shared/infra/preview-generator/preview-generator.service.ts b/apps/server/src/infra/preview-generator/preview-generator.service.ts similarity index 96% rename from apps/server/src/shared/infra/preview-generator/preview-generator.service.ts rename to apps/server/src/infra/preview-generator/preview-generator.service.ts index 72dac25f076..83dca461a2f 100644 --- a/apps/server/src/shared/infra/preview-generator/preview-generator.service.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; +import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { Logger } from '@src/core/logger'; import { subClass } from 'gm'; import { PassThrough } from 'stream'; diff --git a/apps/server/src/shared/infra/preview-generator/preview.producer.spec.ts b/apps/server/src/infra/preview-generator/preview.producer.spec.ts similarity index 100% rename from apps/server/src/shared/infra/preview-generator/preview.producer.spec.ts rename to apps/server/src/infra/preview-generator/preview.producer.spec.ts diff --git a/apps/server/src/shared/infra/preview-generator/preview.producer.ts b/apps/server/src/infra/preview-generator/preview.producer.ts similarity index 97% rename from apps/server/src/shared/infra/preview-generator/preview.producer.ts rename to apps/server/src/infra/preview-generator/preview.producer.ts index 602e2503185..28cf6930830 100644 --- a/apps/server/src/shared/infra/preview-generator/preview.producer.ts +++ b/apps/server/src/infra/preview-generator/preview.producer.ts @@ -1,7 +1,7 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { FilesPreviewEvents, FilesPreviewExchange, RpcMessageProducer } from '@shared/infra/rabbitmq'; +import { FilesPreviewEvents, FilesPreviewExchange, RpcMessageProducer } from '@infra/rabbitmq'; import { Logger } from '@src/core/logger'; import { PreviewFileOptions, PreviewResponseMessage } from './interface'; import { PreviewModuleConfig } from './interface/preview-consumer-config'; diff --git a/apps/server/src/shared/infra/rabbitmq/error.mapper.spec.ts b/apps/server/src/infra/rabbitmq/error.mapper.spec.ts similarity index 97% rename from apps/server/src/shared/infra/rabbitmq/error.mapper.spec.ts rename to apps/server/src/infra/rabbitmq/error.mapper.spec.ts index c3cf506e541..884dfb35158 100644 --- a/apps/server/src/shared/infra/rabbitmq/error.mapper.spec.ts +++ b/apps/server/src/infra/rabbitmq/error.mapper.spec.ts @@ -4,7 +4,7 @@ import { ForbiddenException, InternalServerErrorException, } from '@nestjs/common'; -import { IError } from '@shared/infra/rabbitmq'; +import { IError } from '@infra/rabbitmq'; import _ from 'lodash'; import { ErrorMapper } from './error.mapper'; diff --git a/apps/server/src/shared/infra/rabbitmq/error.mapper.ts b/apps/server/src/infra/rabbitmq/error.mapper.ts similarity index 94% rename from apps/server/src/shared/infra/rabbitmq/error.mapper.ts rename to apps/server/src/infra/rabbitmq/error.mapper.ts index 60f2e73795e..6f7083d3ad9 100644 --- a/apps/server/src/shared/infra/rabbitmq/error.mapper.ts +++ b/apps/server/src/infra/rabbitmq/error.mapper.ts @@ -1,6 +1,6 @@ import { BadRequestException, ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { ErrorUtils } from '@src/core/error/utils'; -import { IError } from '@shared/infra/rabbitmq'; +import { IError } from '@infra/rabbitmq'; export class ErrorMapper { static mapRpcErrorResponseToDomainError( diff --git a/apps/server/src/shared/infra/rabbitmq/exchange/files-preview.ts b/apps/server/src/infra/rabbitmq/exchange/files-preview.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/exchange/files-preview.ts rename to apps/server/src/infra/rabbitmq/exchange/files-preview.ts diff --git a/apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts b/apps/server/src/infra/rabbitmq/exchange/files-storage.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts rename to apps/server/src/infra/rabbitmq/exchange/files-storage.ts diff --git a/apps/server/src/shared/infra/rabbitmq/exchange/index.ts b/apps/server/src/infra/rabbitmq/exchange/index.ts similarity index 97% rename from apps/server/src/shared/infra/rabbitmq/exchange/index.ts rename to apps/server/src/infra/rabbitmq/exchange/index.ts index 0cf6bd00d13..48658a3f0a7 100644 --- a/apps/server/src/shared/infra/rabbitmq/exchange/index.ts +++ b/apps/server/src/infra/rabbitmq/exchange/index.ts @@ -1,2 +1,2 @@ -export * from './files-preview'; -export * from './files-storage'; +export * from './files-preview'; +export * from './files-storage'; diff --git a/apps/server/src/shared/infra/rabbitmq/index.ts b/apps/server/src/infra/rabbitmq/index.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/index.ts rename to apps/server/src/infra/rabbitmq/index.ts diff --git a/apps/server/src/shared/infra/rabbitmq/rabbitmq.module.ts b/apps/server/src/infra/rabbitmq/rabbitmq.module.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/rabbitmq.module.ts rename to apps/server/src/infra/rabbitmq/rabbitmq.module.ts diff --git a/apps/server/src/shared/infra/rabbitmq/rpc-message-producer.spec.ts b/apps/server/src/infra/rabbitmq/rpc-message-producer.spec.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/rpc-message-producer.spec.ts rename to apps/server/src/infra/rabbitmq/rpc-message-producer.spec.ts diff --git a/apps/server/src/shared/infra/rabbitmq/rpc-message-producer.ts b/apps/server/src/infra/rabbitmq/rpc-message-producer.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/rpc-message-producer.ts rename to apps/server/src/infra/rabbitmq/rpc-message-producer.ts diff --git a/apps/server/src/shared/infra/rabbitmq/rpc-message.ts b/apps/server/src/infra/rabbitmq/rpc-message.ts similarity index 100% rename from apps/server/src/shared/infra/rabbitmq/rpc-message.ts rename to apps/server/src/infra/rabbitmq/rpc-message.ts diff --git a/apps/server/src/shared/infra/redis/index.ts b/apps/server/src/infra/redis/index.ts similarity index 100% rename from apps/server/src/shared/infra/redis/index.ts rename to apps/server/src/infra/redis/index.ts diff --git a/apps/server/src/shared/infra/redis/interface/redis.constants.ts b/apps/server/src/infra/redis/interface/redis.constants.ts similarity index 100% rename from apps/server/src/shared/infra/redis/interface/redis.constants.ts rename to apps/server/src/infra/redis/interface/redis.constants.ts diff --git a/apps/server/src/shared/infra/redis/redis.module.ts b/apps/server/src/infra/redis/redis.module.ts similarity index 100% rename from apps/server/src/shared/infra/redis/redis.module.ts rename to apps/server/src/infra/redis/redis.module.ts diff --git a/apps/server/src/shared/infra/s3-client/README.md b/apps/server/src/infra/s3-client/README.md similarity index 100% rename from apps/server/src/shared/infra/s3-client/README.md rename to apps/server/src/infra/s3-client/README.md diff --git a/apps/server/src/shared/infra/s3-client/constants.ts b/apps/server/src/infra/s3-client/constants.ts similarity index 100% rename from apps/server/src/shared/infra/s3-client/constants.ts rename to apps/server/src/infra/s3-client/constants.ts diff --git a/apps/server/src/shared/infra/s3-client/index.ts b/apps/server/src/infra/s3-client/index.ts similarity index 97% rename from apps/server/src/shared/infra/s3-client/index.ts rename to apps/server/src/infra/s3-client/index.ts index a2bfb7428c3..89943618d9e 100644 --- a/apps/server/src/shared/infra/s3-client/index.ts +++ b/apps/server/src/infra/s3-client/index.ts @@ -1,3 +1,3 @@ -export * from './interface'; -export * from './s3-client.adapter'; -export * from './s3-client.module'; +export * from './interface'; +export * from './s3-client.adapter'; +export * from './s3-client.module'; diff --git a/apps/server/src/shared/infra/s3-client/interface/index.ts b/apps/server/src/infra/s3-client/interface/index.ts similarity index 65% rename from apps/server/src/shared/infra/s3-client/interface/index.ts rename to apps/server/src/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/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/infra/s3-client/s3-client.adapter.spec.ts similarity index 72% rename from apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts rename to apps/server/src/infra/s3-client/s3-client.adapter.spec.ts index 957d841f1fe..7986e692ca9 100644 --- a/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts +++ b/apps/server/src/infra/s3-client/s3-client.adapter.spec.ts @@ -1,14 +1,13 @@ 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'; import { Readable } from 'node:stream'; -import { FileDto } from '../../../modules/files-storage/dto'; import { S3_CLIENT, S3_CONFIG } from './constants'; -import { S3Config } from './interface'; +import { File, S3Config } from './interface'; import { S3ClientAdapter } from './s3-client.adapter'; const createParameter = () => { @@ -190,11 +189,10 @@ describe('S3ClientAdapter', () => { describe('create', () => { const createFile = () => { const readable = Readable.from('ddd'); - const file = new FileDto({ + const file: File = { data: readable, - name: 'test.txt', mimeType: 'text/plain', - }); + }; return { file }; }; @@ -564,4 +562,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/infra/s3-client/s3-client.adapter.ts similarity index 75% rename from apps/server/src/shared/infra/s3-client/s3-client.adapter.ts rename to apps/server/src/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/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/infra/s3-client/s3-client.module.spec.ts b/apps/server/src/infra/s3-client/s3-client.module.spec.ts similarity index 100% rename from apps/server/src/shared/infra/s3-client/s3-client.module.spec.ts rename to apps/server/src/infra/s3-client/s3-client.module.spec.ts diff --git a/apps/server/src/shared/infra/s3-client/s3-client.module.ts b/apps/server/src/infra/s3-client/s3-client.module.ts similarity index 100% rename from apps/server/src/shared/infra/s3-client/s3-client.module.ts rename to apps/server/src/infra/s3-client/s3-client.module.ts diff --git a/apps/server/src/modules/account/account.module.spec.ts b/apps/server/src/modules/account/account.module.spec.ts index 74a8d25568b..8acbff04090 100644 --- a/apps/server/src/modules/account/account.module.spec.ts +++ b/apps/server/src/modules/account/account.module.spec.ts @@ -1,6 +1,6 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { AccountModule } from './account.module'; import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb, AccountIdmToDtoMapperIdm } from './mapper'; import { AccountService } from './services/account.service'; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 6c98d7f76e3..2e11af11c6d 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PermissionService } from '@shared/domain'; import { SystemRepo, UserRepo } from '@shared/repo'; -import { IdentityManagementModule } from '@shared/infra/identity-management'; +import { IdentityManagementModule } from '@infra/identity-management'; import { LoggerModule } from '@src/core/logger/logger.module'; import { AccountRepo } from './repo/account.repo'; import { AccountService } from './services/account.service'; diff --git a/apps/server/src/modules/account/index.ts b/apps/server/src/modules/account/index.ts index 2fa0bcc2334..7db4a66a657 100644 --- a/apps/server/src/modules/account/index.ts +++ b/apps/server/src/modules/account/index.ts @@ -1,3 +1,3 @@ export * from './account.module'; export * from './account-config'; -export { AccountService } from './services'; +export { AccountService, AccountDto } from './services'; diff --git a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts index bf4a44119fe..77acc53a3b1 100644 --- a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts +++ b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, User } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { accountFactory, cleanupCollections, userFactory } from '@shared/testing'; import { AccountRepo } from './account.repo'; diff --git a/apps/server/src/modules/account/repo/account.repo.ts b/apps/server/src/modules/account/repo/account.repo.ts index fb68f0a759b..f8c1f5677ec 100644 --- a/apps/server/src/modules/account/repo/account.repo.ts +++ b/apps/server/src/modules/account/repo/account.repo.ts @@ -1,7 +1,7 @@ import { AnyEntity, EntityName, Primary } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain'; +import { EntityId, SortOrder } from '@shared/domain'; import { Account } from '@shared/domain/entity/account.entity'; import { BaseRepo } from '@shared/repo/base.repo'; @@ -71,7 +71,9 @@ export class AccountRepo extends BaseRepo { * @deprecated For migration purpose only */ async findMany(offset = 0, limit = 100): Promise { - return this._em.find(this.entityName, {}, { offset, limit }); + const result = await this._em.find(this.entityName, {}, { offset, limit, orderBy: { _id: SortOrder.asc } }); + this._em.clear(); + return result; } private async searchByUsername( diff --git a/apps/server/src/modules/account/services/account-db.service.spec.ts b/apps/server/src/modules/account/services/account-db.service.spec.ts index 64075bcb40c..107273797f9 100644 --- a/apps/server/src/modules/account/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/services/account-db.service.spec.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { Account, EntityId, Permission, Role, RoleName, SchoolEntity, User } from '@shared/domain'; -import { IdentityManagementService } from '@shared/infra/identity-management/identity-management.service'; +import { IdentityManagementService } from '@infra/identity-management/identity-management.service'; import { accountFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from '@modules/account/mapper'; import { AccountDto } from '@modules/account/services/dto'; diff --git a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts index 4761bbd80ca..06ad0943c22 100644 --- a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts @@ -4,11 +4,10 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount } from '@shared/domain'; -import { KeycloakAdministrationService } from '@shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service'; +import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; import { AccountSaveDto } from '@modules/account/services/dto'; import { LoggerModule } from '@src/core/logger'; -import { IdentityManagementModule } from '@shared/infra/identity-management'; -import { IdentityManagementService } from '../../../shared/infra/identity-management/identity-management.service'; +import { IdentityManagementModule, IdentityManagementService } from '@infra/identity-management'; import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb } from '../mapper'; import { AccountServiceIdm } from './account-idm.service'; import { AbstractAccountService } from './account.service.abstract'; diff --git a/apps/server/src/modules/account/services/account-idm.service.spec.ts b/apps/server/src/modules/account/services/account-idm.service.spec.ts index 4b997d1b3fe..9b8705089b3 100644 --- a/apps/server/src/modules/account/services/account-idm.service.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { IdmAccount } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { IdentityManagementOauthService, IdentityManagementService } from '@shared/infra/identity-management'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { IdentityManagementOauthService, IdentityManagementService } from '@infra/identity-management'; import { NotImplementedException } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; diff --git a/apps/server/src/modules/account/services/account-idm.service.ts b/apps/server/src/modules/account/services/account-idm.service.ts index 68bcfb42bae..2136326e038 100644 --- a/apps/server/src/modules/account/services/account-idm.service.ts +++ b/apps/server/src/modules/account/services/account-idm.service.ts @@ -2,7 +2,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, NotImplementedException } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { Counted, EntityId, IdmAccount, IdmAccountUpdate } from '@shared/domain'; -import { IdentityManagementService, IdentityManagementOauthService } from '@shared/infra/identity-management'; +import { IdentityManagementService, IdentityManagementOauthService } from '@infra/identity-management'; import { LegacyLogger } from '@src/core/logger'; import { AccountIdmToDtoMapper } from '../mapper'; import { AbstractAccountService } from './account.service.abstract'; diff --git a/apps/server/src/modules/account/services/account-lookup.service.spec.ts b/apps/server/src/modules/account/services/account-lookup.service.spec.ts index cfef246d3e3..c3351a6b0c3 100644 --- a/apps/server/src/modules/account/services/account-lookup.service.spec.ts +++ b/apps/server/src/modules/account/services/account-lookup.service.spec.ts @@ -4,7 +4,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount } from '@shared/domain'; -import { IdentityManagementService } from '@shared/infra/identity-management'; +import { IdentityManagementService } from '@infra/identity-management'; import { AccountLookupService } from './account-lookup.service'; describe('AccountLookupService', () => { diff --git a/apps/server/src/modules/account/services/account-lookup.service.ts b/apps/server/src/modules/account/services/account-lookup.service.ts index b1549590c5b..ed67d03232d 100644 --- a/apps/server/src/modules/account/services/account-lookup.service.ts +++ b/apps/server/src/modules/account/services/account-lookup.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EntityId } from '@shared/domain'; -import { IdentityManagementService } from '@shared/infra/identity-management'; +import { IdentityManagementService } from '@infra/identity-management'; import { IServerConfig } from '@modules/server/server.config'; import { ObjectId } from 'bson'; diff --git a/apps/server/src/modules/account/services/account.service.integration.spec.ts b/apps/server/src/modules/account/services/account.service.integration.spec.ts index d001925000b..ccaf450ffed 100644 --- a/apps/server/src/modules/account/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account.service.integration.spec.ts @@ -4,10 +4,10 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, IdmAccount } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { IdentityManagementModule } from '@shared/infra/identity-management'; -import { IdentityManagementService } from '@shared/infra/identity-management/identity-management.service'; -import { KeycloakAdministrationService } from '@shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { IdentityManagementModule } from '@infra/identity-management'; +import { IdentityManagementService } from '@infra/identity-management/identity-management.service'; +import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; import { UserRepo } from '@shared/repo'; import { accountFactory, cleanupCollections } from '@shared/testing'; import { ObjectId } from 'bson'; diff --git a/apps/server/src/modules/account/services/index.ts b/apps/server/src/modules/account/services/index.ts index 72778be1f1e..2fc3f3a3246 100644 --- a/apps/server/src/modules/account/services/index.ts +++ b/apps/server/src/modules/account/services/index.ts @@ -1 +1,2 @@ export * from './account.service'; +export { AccountDto } from './dto'; diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index 26d20a4dfc8..8f2bdcd3b0d 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { CacheWrapperModule } from '@shared/infra/cache'; -import { IdentityManagementModule } from '@shared/infra/identity-management'; +import { CacheWrapperModule } from '@infra/cache'; +import { IdentityManagementModule } from '@infra/identity-management'; import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AccountModule } from '@modules/account'; diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 7da3c21eab9..04683e182a8 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -192,6 +192,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).toHaveProperty('isExternalUser'); expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -287,7 +288,7 @@ describe('Login Controller (api)', () => { roles: [studentRole.id], schoolId: school.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); expect(decodedToken).not.toHaveProperty('externalIdToken'); }); diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index 904c64ff97b..59e749c7abc 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -1,2 +1,3 @@ export { ICurrentUser } from './interface'; export { JWT, CurrentUser, Authenticate } from './decorator'; +export { AuthenticationModule } from './authentication.module'; diff --git a/apps/server/src/modules/authentication/interface/index.ts b/apps/server/src/modules/authentication/interface/index.ts index e5abc856509..a9de8109a5b 100644 --- a/apps/server/src/modules/authentication/interface/index.ts +++ b/apps/server/src/modules/authentication/interface/index.ts @@ -1 +1,2 @@ export * from './user'; +export * from './oauth-current-user'; diff --git a/apps/server/src/modules/authentication/interface/oauth-current-user.ts b/apps/server/src/modules/authentication/interface/oauth-current-user.ts new file mode 100644 index 00000000000..ddf15e1ca5d --- /dev/null +++ b/apps/server/src/modules/authentication/interface/oauth-current-user.ts @@ -0,0 +1,6 @@ +import { ICurrentUser } from './user'; + +export interface OauthCurrentUser extends ICurrentUser { + /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ + externalIdToken?: string; +} diff --git a/apps/server/src/modules/authentication/interface/user.ts b/apps/server/src/modules/authentication/interface/user.ts index cc8423f69b7..82b6d292d50 100644 --- a/apps/server/src/modules/authentication/interface/user.ts +++ b/apps/server/src/modules/authentication/interface/user.ts @@ -16,11 +16,6 @@ export interface ICurrentUser { /** True if a support member impersonates the user */ impersonated?: boolean; - /** True if the user is an external user e.g. an oauth user */ + /** True if the user is an external user e.g. an oauth user or ldap user */ isExternalUser: boolean; } - -export interface OauthCurrentUser extends ICurrentUser { - /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ - externalIdToken?: string; -} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index d06bea6d080..104ca1219a4 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -15,42 +15,78 @@ describe('CurrentUserMapper', () => { describe('userToICurrentUser', () => { describe('when mapping from a user entity to the current user object', () => { - it('should map with roles', () => { - const teacherRole = roleFactory.buildWithId({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] }); - const user = userFactory.buildWithId({ - roles: [teacherRole], - }); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ - accountId, - systemId: undefined, - roles: [teacherRole.id], - schoolId: null, + describe('when user has roles', () => { + const setup = () => { + const teacherRole = roleFactory.buildWithId({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT], + }); + const user = userFactory.buildWithId({ + roles: [teacherRole], + }); + + return { + teacherRole, + user, + }; + }; + + it('should map with roles', () => { + const { teacherRole, user } = setup(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, false); + + expect(currentUser).toMatchObject({ + accountId, + systemId: undefined, + roles: [teacherRole.id], + schoolId: null, + isExternalUser: false, + }); }); }); - it('should map without roles', () => { - const user = userFactory.buildWithId(); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ - accountId, - systemId: undefined, - roles: [], - schoolId: null, + describe('when user has no roles', () => { + it('should map without roles', () => { + const user = userFactory.buildWithId(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, true); + + expect(currentUser).toMatchObject({ + accountId, + systemId: undefined, + roles: [], + schoolId: null, + isExternalUser: true, + }); }); }); - it('should map system and school', () => { - const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(), - }); - const systemId = 'mockSystemId'; - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, systemId); - expect(currentUser).toMatchObject({ - accountId, - systemId, - roles: [], - schoolId: user.school.id, + describe('when systemId is provided', () => { + const setup = () => { + const user = userFactory.buildWithId({ + school: schoolFactory.buildWithId(), + }); + const systemId = 'mockSystemId'; + + return { + user, + systemId, + }; + }; + + it('should map system and school', () => { + const { user, systemId } = setup(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, false, systemId); + + expect(currentUser).toMatchObject({ + accountId, + systemId, + roles: [], + schoolId: user.school.id, + isExternalUser: false, + }); }); }); }); diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts index ab832b70d8c..35dd6c5fe7c 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts @@ -6,14 +6,14 @@ import { ICurrentUser, OauthCurrentUser } from '../interface'; import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; export class CurrentUserMapper { - static userToICurrentUser(accountId: string, user: User, systemId?: string): ICurrentUser { + static userToICurrentUser(accountId: string, user: User, isExternalUser: boolean, systemId?: string): ICurrentUser { return { accountId, systemId, roles: user.roles.getItems().map((role: Role) => role.id), schoolId: user.school.id, userId: user.id, - isExternalUser: false, + isExternalUser, }; } diff --git a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts index 936deb866e4..6638e0470b2 100644 --- a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts +++ b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test, TestingModule } from '@nestjs/testing'; -import { CacheService } from '@shared/infra/cache'; -import { CacheStoreType } from '@shared/infra/cache/interface/cache-store-type.enum'; +import { CacheService } from '@infra/cache'; +import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; import { feathersRedis } from '@src/imports-from-feathers'; import { Cache } from 'cache-manager'; import { JwtValidationAdapter } from './jwt-validation.adapter'; diff --git a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts index 3af5db2061b..dee98747c46 100644 --- a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts +++ b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts @@ -1,7 +1,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable } from '@nestjs/common'; -import { CacheService } from '@shared/infra/cache'; -import { CacheStoreType } from '@shared/infra/cache/interface/cache-store-type.enum'; +import { CacheService } from '@infra/cache'; +import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; import { addTokenToWhitelist, createRedisIdentifierFromJwtData, diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index 78f445ce5b0..5b23be9eb74 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -436,7 +436,7 @@ describe('LdapStrategy', () => { schoolId: school.id, systemId: system.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); }); }); @@ -501,7 +501,7 @@ describe('LdapStrategy', () => { schoolId: school.id, systemId: system.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index 1622e434310..6f33e92f21a 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -1,10 +1,10 @@ +import { AccountDto } from '@modules/account/services/dto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo, SystemEntity, User } from '@shared/domain'; import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; -import { AccountDto } from '@modules/account/services/dto'; import { Strategy } from 'passport-custom'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; import { ICurrentUser } from '../interface'; @@ -48,7 +48,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { await this.checkCredentials(account, system, ldapDn, password); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(account.id, user, systemId); + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(account.id, user, true, systemId); return currentUser; } diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts index d1330270fb7..121d2874fe9 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { RoleName, User } from '@shared/domain'; -import { IdentityManagementOauthService } from '@shared/infra/identity-management'; +import { IdentityManagementOauthService } from '@infra/identity-management'; import { UserRepo } from '@shared/repo'; import { accountFactory, setupEntities, userFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from '@modules/account/mapper'; diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.ts b/apps/server/src/modules/authentication/strategy/local.strategy.ts index 7963a5166e7..1d31a86d833 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.ts @@ -6,7 +6,7 @@ import bcrypt from 'bcryptjs'; import { UserRepo } from '@shared/repo'; import { AccountDto } from '@modules/account/services/dto'; import { GuardAgainst } from '@shared/common/utils/guard-against'; -import { IdentityManagementOauthService, IIdentityManagementConfig } from '@shared/infra/identity-management'; +import { IdentityManagementOauthService, IIdentityManagementConfig } from '@infra/identity-management'; import { CurrentUserMapper } from '../mapper'; import { ICurrentUser } from '../interface'; import { AuthenticationService } from '../services/authentication.service'; @@ -39,7 +39,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { new Error(`login failing, because account ${account.id} has no userId`) ); const user = await this.userRepo.findById(accountUserId, true); - const currentUser = CurrentUserMapper.userToICurrentUser(account.id, user); + const currentUser = CurrentUserMapper.userToICurrentUser(account.id, user, false); return currentUser; } diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index c555f13dc7b..d01cd9363f4 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { FeathersModule } from '@shared/infra/feathers'; +import { FeathersModule } from '@infra/feathers'; import { BoardDoRule, ContextExternalToolRule, diff --git a/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts b/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts index f36ca235af1..01f24b21985 100644 --- a/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts +++ b/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts @@ -7,7 +7,7 @@ export enum AuthorizableReferenceType { 'Lesson' = 'lessons', 'Team' = 'teams', 'Submission' = 'submissions', - 'SchoolExternalToolEntity' = 'school_external_tools', + 'SchoolExternalToolEntity' = 'school-external-tools', 'BoardNode' = 'boardnodes', - 'ContextExternalToolEntity' = 'context_external_tools', + 'ContextExternalToolEntity' = 'context-external-tools', } diff --git a/apps/server/src/modules/authorization/feathers/feathers-auth.provider.spec.ts b/apps/server/src/modules/authorization/feathers/feathers-auth.provider.spec.ts index ccf1f2177aa..f035fa5a867 100644 --- a/apps/server/src/modules/authorization/feathers/feathers-auth.provider.spec.ts +++ b/apps/server/src/modules/authorization/feathers/feathers-auth.provider.spec.ts @@ -2,7 +2,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; import { Test, TestingModule } from '@nestjs/testing'; import { NewsTargetModel } from '@shared/domain'; -import { FeathersServiceProvider } from '@shared/infra/feathers'; +import { FeathersServiceProvider } from '@infra/feathers'; import { FeathersAuthProvider } from './feathers-auth.provider'; describe('FeathersAuthProvider', () => { diff --git a/apps/server/src/modules/authorization/feathers/feathers-auth.provider.ts b/apps/server/src/modules/authorization/feathers/feathers-auth.provider.ts index 1f3f40886eb..4e4f87c56d1 100644 --- a/apps/server/src/modules/authorization/feathers/feathers-auth.provider.ts +++ b/apps/server/src/modules/authorization/feathers/feathers-auth.provider.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { BaseEntity, EntityId, NewsTargetModel } from '@shared/domain'; import { ObjectId } from '@mikro-orm/mongodb'; -import { FeathersServiceProvider } from '@shared/infra/feathers'; +import { FeathersServiceProvider } from '@infra/feathers'; interface User { _id: ObjectId; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 7722326a21d..ffa1e7ad580 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -3,7 +3,7 @@ import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { ContentElementFactory } from '@shared/domain'; -import { ConsoleWriterModule } from '@shared/infra/console'; +import { ConsoleWriterModule } from '@infra/console'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; diff --git a/apps/server/src/modules/board/controller/dto/element/file-element.response.ts b/apps/server/src/modules/board/controller/dto/element/file-element.response.ts index 0fa23a7f735..d6c5122358f 100644 --- a/apps/server/src/modules/board/controller/dto/element/file-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/file-element.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { DecodeHtmlEntities } from '@shared/controller'; import { ContentElementType } from '@shared/domain'; import { TimestampsResponse } from '../timestamps.response'; @@ -9,9 +10,11 @@ export class FileElementContent { } @ApiProperty() + @DecodeHtmlEntities() caption: string; @ApiProperty() + @DecodeHtmlEntities() alternativeText: string; } diff --git a/apps/server/src/modules/board/repo/board-do.repo.spec.ts b/apps/server/src/modules/board/repo/board-do.repo.spec.ts index 3874e9301ba..2f9a6633e69 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.spec.ts @@ -14,7 +14,7 @@ import { ColumnBoard, RichTextElementNode, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cardFactory, cardNodeFactory, diff --git a/apps/server/src/modules/board/repo/board-node.repo.spec.ts b/apps/server/src/modules/board/repo/board-node.repo.spec.ts index 656d751f01d..2ebed80d0af 100644 --- a/apps/server/src/modules/board/repo/board-node.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-node.repo.spec.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { ColumnBoardNode } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cardNodeFactory, cleanupCollections, diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 6236d5de8bb..4b0688f0a0d 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -3,7 +3,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Test, TestingModule } from '@nestjs/testing'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { columnBoardFactory, columnFactory, diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index d7c71352166..40a62ede2ed 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -18,7 +18,7 @@ import { RichTextElement, SubmissionContainerElement, } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { cardFactory, columnBoardFactory, diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index ba76693bb93..137f189319c 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -12,7 +12,7 @@ import { SubmissionItem, } from '@shared/domain'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { ObjectId } from 'bson'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; diff --git a/apps/server/src/modules/board/uc/board-management.uc.spec.ts b/apps/server/src/modules/board/uc/board-management.uc.spec.ts index 7c3464a8a52..83948e0ae1e 100644 --- a/apps/server/src/modules/board/uc/board-management.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board-management.uc.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { ConsoleWriterService } from '@infra/console'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { courseFactory } from '@shared/testing'; import { BoardManagementUc } from '@modules/management/uc/board-management.uc'; diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts index 7801045aff0..df0191d24ef 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing/testing-module'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { SchoolEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, schoolFactory } from '@shared/testing'; import { Class } from '../domain'; import { ClassEntity } from '../entity'; diff --git a/apps/server/src/modules/collaborative-storage/collaborative-storage.module.ts b/apps/server/src/modules/collaborative-storage/collaborative-storage.module.ts index eedf1b5638e..a654ff86bdc 100644 --- a/apps/server/src/modules/collaborative-storage/collaborative-storage.module.ts +++ b/apps/server/src/modules/collaborative-storage/collaborative-storage.module.ts @@ -1,14 +1,13 @@ import { Module } from '@nestjs/common'; -import { CollaborativeStorageAdapterModule } from '@shared/infra/collaborative-storage/collaborative-storage-adapter.module'; +import { CollaborativeStorageAdapterModule } from '@infra/collaborative-storage'; import { TeamsRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; -import { TeamPermissionsMapper } from '@modules/collaborative-storage/mapper/team-permissions.mapper'; -import { TeamMapper } from '@modules/collaborative-storage/mapper/team.mapper'; -import { CollaborativeStorageService } from '@modules/collaborative-storage/services/collaborative-storage.service'; -import { RoleModule } from '@modules/role/role.module'; -import { CollaborativeStorageController } from './controller/collaborative-storage.controller'; -import { CollaborativeStorageUc } from './uc/collaborative-storage.uc'; +import { RoleModule } from '@modules/role'; +import { CollaborativeStorageService } from './services'; +import { TeamPermissionsMapper, TeamMapper } from './mapper'; +import { CollaborativeStorageController } from './controller'; +import { CollaborativeStorageUc } from './uc'; @Module({ imports: [CollaborativeStorageAdapterModule, AuthorizationModule, LoggerModule, RoleModule], diff --git a/apps/server/src/modules/collaborative-storage/controller/index.ts b/apps/server/src/modules/collaborative-storage/controller/index.ts new file mode 100644 index 00000000000..6e3d2c24555 --- /dev/null +++ b/apps/server/src/modules/collaborative-storage/controller/index.ts @@ -0,0 +1 @@ +export * from './collaborative-storage.controller'; diff --git a/apps/server/src/modules/collaborative-storage/index.ts b/apps/server/src/modules/collaborative-storage/index.ts index 55ed293fd82..6a36bd891cf 100644 --- a/apps/server/src/modules/collaborative-storage/index.ts +++ b/apps/server/src/modules/collaborative-storage/index.ts @@ -1,2 +1,2 @@ -export * from './collaborative-storage.module'; -export * from './services'; +export { CollaborativeStorageModule } from './collaborative-storage.module'; +export { CollaborativeStorageService, TeamDto, TeamPermissionsDto, TeamUserDto } from './services'; diff --git a/apps/server/src/modules/collaborative-storage/mapper/index.ts b/apps/server/src/modules/collaborative-storage/mapper/index.ts new file mode 100644 index 00000000000..df052363bc4 --- /dev/null +++ b/apps/server/src/modules/collaborative-storage/mapper/index.ts @@ -0,0 +1,2 @@ +export * from './team-permissions.mapper'; +export * from './team.mapper'; diff --git a/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.spec.ts b/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.spec.ts index 4f95ae44a11..a1f8757f576 100644 --- a/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.spec.ts +++ b/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName, TeamEntity } from '@shared/domain'; -import { CollaborativeStorageAdapter } from '@shared/infra/collaborative-storage'; +import { CollaborativeStorageAdapter } from '@infra/collaborative-storage'; import { TeamsRepo } from '@shared/repo'; import { setupEntities } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; diff --git a/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.ts b/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.ts index f9807cf691c..8f32f0ffc5f 100644 --- a/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.ts +++ b/apps/server/src/modules/collaborative-storage/services/collaborative-storage.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; -import { CollaborativeStorageAdapter } from '@shared/infra/collaborative-storage'; +import { CollaborativeStorageAdapter } from '@infra/collaborative-storage'; import { TeamsRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; diff --git a/apps/server/src/modules/collaborative-storage/uc/index.ts b/apps/server/src/modules/collaborative-storage/uc/index.ts new file mode 100644 index 00000000000..b08c347bf88 --- /dev/null +++ b/apps/server/src/modules/collaborative-storage/uc/index.ts @@ -0,0 +1 @@ +export * from './collaborative-storage.uc'; diff --git a/apps/server/src/modules/deletion/deletion.module.ts b/apps/server/src/modules/deletion/deletion.module.ts new file mode 100644 index 00000000000..440a9418d70 --- /dev/null +++ b/apps/server/src/modules/deletion/deletion.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { DeletionRequestService } from './services/deletion-request.service'; +import { DeletionRequestRepo } from './repo/deletion-request.repo'; + +@Module({ + imports: [LoggerModule], + providers: [DeletionRequestService, DeletionRequestRepo], + exports: [DeletionRequestService], +}) +export class DeletionModule {} diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts new file mode 100644 index 00000000000..9117ded29c5 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts @@ -0,0 +1,70 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { deletionLogFactory } from './testing/factory/deletion-log.factory'; +import { DeletionLog } from './deletion-log.do'; +import { DeletionOperationModel } from './types/deletion-operation-model.enum'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; + +describe(DeletionLog.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a deletionRequest by passing required properties', () => { + const domainObject: DeletionLog = deletionLogFactory.build(); + + expect(domainObject instanceof DeletionLog).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: DeletionLog = deletionLogFactory.buildWithId(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const deletionLogDomainObject: DeletionLog = new DeletionLog(domainObject); + + expect(deletionLogDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const deletionLogDo = new DeletionLog(props); + + return { props, deletionLogDo }; + }; + it('getters should return proper values', () => { + const { props, deletionLogDo } = setup(); + + const gettersValues = { + id: deletionLogDo.id, + domain: deletionLogDo.domain, + operation: deletionLogDo.operation, + modifiedCount: deletionLogDo.modifiedCount, + deletedCount: deletionLogDo.deletedCount, + deletionRequestId: deletionLogDo.deletionRequestId, + createdAt: deletionLogDo.createdAt, + updatedAt: deletionLogDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.ts new file mode 100644 index 00000000000..73e62b46055 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.ts @@ -0,0 +1,44 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { DeletionOperationModel } from './types/deletion-operation-model.enum'; + +export interface DeletionLogProps extends AuthorizableObject { + createdAt?: Date; + updatedAt?: Date; + domain: DeletionDomainModel; + operation?: DeletionOperationModel; + modifiedCount?: number; + deletedCount?: number; + deletionRequestId?: EntityId; +} + +export class DeletionLog extends DomainObject { + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + get domain(): DeletionDomainModel { + return this.props.domain; + } + + get operation(): DeletionOperationModel | undefined { + return this.props.operation; + } + + get modifiedCount(): number | undefined { + return this.props.modifiedCount; + } + + get deletedCount(): number | undefined { + return this.props.deletedCount; + } + + get deletionRequestId(): EntityId | undefined { + return this.props.deletionRequestId; + } +} diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts new file mode 100644 index 00000000000..3c0eb608c87 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequest } from './deletion-request.do'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { deletionRequestFactory } from './testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from './types/deletion-status-model.enum'; + +describe(DeletionRequest.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a deletionRequest by passing required properties', () => { + const domainObject: DeletionRequest = deletionRequestFactory.build(); + + expect(domainObject instanceof DeletionRequest).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: DeletionRequest = deletionRequestFactory.buildWithId(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const deletionRequestDomainObject: DeletionRequest = new DeletionRequest(domainObject); + + expect(deletionRequestDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const deletionRequestDo = new DeletionRequest(props); + + return { props, deletionRequestDo }; + }; + + it('getters should return proper values', () => { + const { props, deletionRequestDo } = setup(); + + const gettersValues = { + id: deletionRequestDo.id, + targetRefDomain: deletionRequestDo.targetRefDomain, + deleteAfter: deletionRequestDo.deleteAfter, + targetRefId: deletionRequestDo.targetRefId, + status: deletionRequestDo.status, + createdAt: deletionRequestDo.createdAt, + updatedAt: deletionRequestDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.ts new file mode 100644 index 00000000000..e1a8b289ef0 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.ts @@ -0,0 +1,39 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { DeletionStatusModel } from './types/deletion-status-model.enum'; + +export interface DeletionRequestProps extends AuthorizableObject { + createdAt?: Date; + updatedAt?: Date; + targetRefDomain: DeletionDomainModel; + deleteAfter: Date; + targetRefId: EntityId; + status: DeletionStatusModel; +} + +export class DeletionRequest extends DomainObject { + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + get targetRefDomain(): DeletionDomainModel { + return this.props.targetRefDomain; + } + + get deleteAfter(): Date { + return this.props.deleteAfter; + } + + get targetRefId(): EntityId { + return this.props.targetRefId; + } + + get status(): DeletionStatusModel { + return this.props.status; + } +} diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts new file mode 100644 index 00000000000..d83b2f44c8a --- /dev/null +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts @@ -0,0 +1,18 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLog, DeletionLogProps } from '../../deletion-log.do'; +import { DeletionOperationModel } from '../../types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../../types/deletion-domain-model.enum'; + +export const deletionLogFactory = DoBaseFactory.define(DeletionLog, () => { + return { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts new file mode 100644 index 00000000000..9f87bbc1cbf --- /dev/null +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts @@ -0,0 +1,28 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeepPartial } from 'fishery'; +import { DeletionRequest, DeletionRequestProps } from '../../deletion-request.do'; +import { DeletionDomainModel } from '../../types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../../types/deletion-status-model.enum'; + +class DeletionRequestFactory extends DoBaseFactory { + withUserIds(id: string): this { + const params: DeepPartial = { + targetRefId: id, + }; + + return this.params(params); + } +} + +export const deletionRequestFactory = DeletionRequestFactory.define(DeletionRequest, () => { + return { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts new file mode 100644 index 00000000000..1a4f3bcf425 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -0,0 +1,12 @@ +export const enum DeletionDomainModel { + ACCOUNT = 'account', + CLASS = 'class', + COURSEGROUP = 'courseGroup', + COURSE = 'course', + FILE = 'file', + LESSONS = 'lessons', + PSEUDONYMS = 'pseudonyms', + ROCKETCHATUSER = 'rocketChatUser', + TEAMS = 'teams', + USER = 'user', +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts new file mode 100644 index 00000000000..675189e634b --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionOperationModel { + DELETE = 'delete', + UPDATE = 'update', +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts new file mode 100644 index 00000000000..5681d1be214 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts @@ -0,0 +1,5 @@ +export const enum DeletionStatusModel { + FAILED = 'failed', + REGISTERED = 'registered', + SUCCESS = 'success', +} diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts new file mode 100644 index 00000000000..4f9f098cbb3 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts @@ -0,0 +1,60 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogEntity } from './deletion-log.entity'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; + +describe(DeletionLogEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('When constructor is called', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new DeletionLogEntity(); + expect(test).toThrow(); + }); + + it('should create a deletionLog by passing required properties', () => { + const { props } = setup(); + const entity: DeletionLogEntity = new DeletionLogEntity(props); + + expect(entity instanceof DeletionLogEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: DeletionLogEntity = new DeletionLogEntity(props); + + const entityProps = { + id: entity.id, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts new file mode 100644 index 00000000000..8a9d2bab025 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts @@ -0,0 +1,67 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps, EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; + +export interface DeletionLogEntityProps { + id?: EntityId; + domain: DeletionDomainModel; + operation?: DeletionOperationModel; + modifiedCount?: number; + deletedCount?: number; + deletionRequestId?: ObjectId; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'deletionlogs' }) +export class DeletionLogEntity extends BaseEntityWithTimestamps { + @Property() + domain: DeletionDomainModel; + + @Property({ nullable: true }) + operation?: DeletionOperationModel; + + @Property({ nullable: true }) + modifiedCount?: number; + + @Property({ nullable: true }) + deletedCount?: number; + + @Property({ nullable: true }) + deletionRequestId?: ObjectId; + + constructor(props: DeletionLogEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + + this.domain = props.domain; + + if (props.operation !== undefined) { + this.operation = props.operation; + } + + if (props.modifiedCount !== undefined) { + this.modifiedCount = props.modifiedCount; + } + + if (props.deletedCount !== undefined) { + this.deletedCount = props.deletedCount; + } + + if (props.deletionRequestId !== undefined) { + this.deletionRequestId = props.deletionRequestId; + } + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } +} diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts new file mode 100644 index 00000000000..6a0e416d580 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts @@ -0,0 +1,85 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequestEntity } from '@src/modules/deletion/entity/deletion-request.entity'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new DeletionRequestEntity(); + expect(test).toThrow(); + }); + + it('should create a deletionRequest by passing required properties', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + expect(entity instanceof DeletionRequestEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + const entityProps = { + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); + + describe('executed', () => { + it('should update status with value success', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + entity.executed(); + + expect(entity.status).toEqual(DeletionStatusModel.SUCCESS); + }); + }); + + describe('failed', () => { + it('should update status with value failed', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + entity.failed(); + + expect(entity.status).toEqual(DeletionStatusModel.FAILED); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts new file mode 100644 index 00000000000..150fed4d91e --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts @@ -0,0 +1,60 @@ +import { Entity, Index, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +export interface DeletionRequestEntityProps { + id?: EntityId; + targetRefDomain: DeletionDomainModel; + deleteAfter: Date; + targetRefId: EntityId; + status: DeletionStatusModel; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'deletionrequests' }) +@Index({ properties: ['targetRefId', 'targetRefDomain'] }) +export class DeletionRequestEntity extends BaseEntityWithTimestamps { + @Property() + deleteAfter: Date; + + @Property() + targetRefId: EntityId; + + @Property() + targetRefDomain: DeletionDomainModel; + + @Property() + @Index() + status: DeletionStatusModel; + + constructor(props: DeletionRequestEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + + this.targetRefDomain = props.targetRefDomain; + this.deleteAfter = props.deleteAfter; + this.targetRefId = props.targetRefId; + this.status = props.status; + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } + + public executed(): void { + this.status = DeletionStatusModel.SUCCESS; + } + + public failed(): void { + this.status = DeletionStatusModel.FAILED; + } +} diff --git a/apps/server/src/modules/deletion/entity/index.ts b/apps/server/src/modules/deletion/entity/index.ts new file mode 100644 index 00000000000..7e3e31dcd19 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-request.entity'; +export * from './deletion-log.entity'; diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts new file mode 100644 index 00000000000..897fba6820a --- /dev/null +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts @@ -0,0 +1,21 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; +import { DeletionOperationModel } from '../../../domain/types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../../../domain/types/deletion-domain-model.enum'; + +export const deletionLogEntityFactory = BaseFactory.define( + DeletionLogEntity, + () => { + return { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts new file mode 100644 index 00000000000..3ccba779e3e --- /dev/null +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts @@ -0,0 +1,20 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { DeletionStatusModel } from '../../../domain/types/deletion-status-model.enum'; +import { DeletionRequestEntity, DeletionRequestEntityProps } from '../../deletion-request.entity'; +import { DeletionDomainModel } from '../../../domain/types/deletion-domain-model.enum'; + +export const deletionRequestEntityFactory = BaseFactory.define( + DeletionRequestEntity, + () => { + return { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts new file mode 100644 index 00000000000..bd89c1e8d84 --- /dev/null +++ b/apps/server/src/modules/deletion/index.ts @@ -0,0 +1,2 @@ +export * from './deletion.module'; +export * from './services'; diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts new file mode 100644 index 00000000000..bba32408e84 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts @@ -0,0 +1,190 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { DeletionLogMapper } from './mapper'; +import { DeletionLogEntity } from '../entity'; +import { DeletionLogRepo } from './deletion-log.repo'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { deletionLogEntityFactory } from '../entity/testing/factory/deletion-log.entity.factory'; + +describe(DeletionLogRepo.name, () => { + let module: TestingModule; + let repo: DeletionLogRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [DeletionLogEntity], + }), + ], + providers: [DeletionLogRepo, DeletionLogMapper], + }).compile(); + + repo = module.get(DeletionLogRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(DeletionLogEntity); + }); + }); + + describe('create deletionLog', () => { + describe('when deletionLog is new', () => { + const setup = () => { + const domainObject: DeletionLog = deletionLogFactory.build(); + const deletionLogId = domainObject.id; + + const expectedDomainObject = { + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + deletionRequestId: domainObject.deletionRequestId, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }; + + return { domainObject, deletionLogId, expectedDomainObject }; + }; + it('should create a new deletionLog', async () => { + const { domainObject, deletionLogId, expectedDomainObject } = setup(); + await repo.create(domainObject); + + const result = await repo.findById(deletionLogId); + + expect(result).toEqual(expect.objectContaining(expectedDomainObject)); + }); + }); + }); + + describe('findById', () => { + describe('when searching by Id', () => { + const setup = async () => { + // Test deletionLog entity + const entity: DeletionLogEntity = deletionLogEntityFactory.build(); + await em.persistAndFlush(entity); + + const expectedDeletionLog = { + id: entity.id, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId?.toHexString(), + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedDeletionLog, + }; + }; + + it('should find the deletionRequest', async () => { + const { entity, expectedDeletionLog } = await setup(); + + const result: DeletionLog = await repo.findById(entity.id); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedDeletionLog)); + }); + }); + }); + + describe('findAllByDeletionRequestId', () => { + describe('when there is no deletionLog for deletionRequestId', () => { + it('should return empty array', async () => { + const deletionRequestId = new ObjectId().toHexString(); + const result = await repo.findAllByDeletionRequestId(deletionRequestId); + + expect(result).toEqual([]); + }); + }); + + describe('when searching by deletionRequestId', () => { + const setup = async () => { + const deletionRequest1Id = new ObjectId(); + const deletionRequest2Id = new ObjectId(); + const deletionLogEntity1: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest1Id, + }); + const deletionLogEntity2: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest1Id, + }); + const deletionLogEntity3: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest2Id, + }); + + await em.persistAndFlush([deletionLogEntity1, deletionLogEntity2, deletionLogEntity3]); + em.clear(); + + const expectedArray = [ + { + id: deletionLogEntity1.id, + domain: deletionLogEntity1.domain, + operation: deletionLogEntity1.operation, + deletionRequestId: deletionLogEntity1.deletionRequestId?.toHexString(), + modifiedCount: deletionLogEntity1.modifiedCount, + deletedCount: deletionLogEntity1.deletedCount, + createdAt: deletionLogEntity1.createdAt, + updatedAt: deletionLogEntity1.updatedAt, + }, + { + id: deletionLogEntity2.id, + domain: deletionLogEntity2.domain, + operation: deletionLogEntity2.operation, + deletionRequestId: deletionLogEntity2.deletionRequestId?.toHexString(), + modifiedCount: deletionLogEntity2.modifiedCount, + deletedCount: deletionLogEntity2.deletedCount, + createdAt: deletionLogEntity2.createdAt, + updatedAt: deletionLogEntity2.updatedAt, + }, + ]; + + return { deletionLogEntity3, deletionRequest1Id, expectedArray }; + }; + + it('should find deletionRequests with deleteAfter smaller then today', async () => { + const { deletionLogEntity3, deletionRequest1Id, expectedArray } = await setup(); + + const results = await repo.findAllByDeletionRequestId(deletionRequest1Id.toHexString()); + + expect(results.length).toEqual(2); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + + const result: DeletionLog = await repo.findById(deletionLogEntity3.id); + + expect(result.id).toEqual(deletionLogEntity3.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts new file mode 100644 index 00000000000..d71032eb124 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts @@ -0,0 +1,41 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { DeletionLogEntity } from '../entity/deletion-log.entity'; +import { DeletionLogMapper } from './mapper/deletion-log.mapper'; + +@Injectable() +export class DeletionLogRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return DeletionLogEntity; + } + + async findById(deletionLogId: EntityId): Promise { + const deletionLog: DeletionLogEntity = await this.em.findOneOrFail(DeletionLogEntity, { + id: deletionLogId, + }); + + const mapped: DeletionLog = DeletionLogMapper.mapToDO(deletionLog); + + return mapped; + } + + async findAllByDeletionRequestId(deletionRequestId: EntityId): Promise { + const deletionLogEntities: DeletionLogEntity[] = await this.em.find(DeletionLogEntity, { + deletionRequestId: new ObjectId(deletionRequestId), + }); + + const mapped: DeletionLog[] = DeletionLogMapper.mapToDOs(deletionLogEntities); + + return mapped; + } + + async create(deletionLog: DeletionLog): Promise { + const deletionLogEntity: DeletionLogEntity = DeletionLogMapper.mapToEntity(deletionLog); + this.em.persist(deletionLogEntity); + await this.em.flush(); + } +} diff --git a/apps/server/src/modules/deletion/repo/deletion-request-scope.ts b/apps/server/src/modules/deletion/repo/deletion-request-scope.ts new file mode 100644 index 00000000000..202bc09a887 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request-scope.ts @@ -0,0 +1,17 @@ +import { Scope } from '@shared/repo'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +export class DeletionRequestScope extends Scope { + byDeleteAfter(currentDate: Date): DeletionRequestScope { + this.addQuery({ deleteAfter: { $lt: currentDate } }); + + return this; + } + + byStatus(): DeletionRequestScope { + this.addQuery({ status: [DeletionStatusModel.REGISTERED, DeletionStatusModel.FAILED] }); + + return this; + } +} diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts new file mode 100644 index 00000000000..c3018180218 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts @@ -0,0 +1,342 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { DeletionRequestMapper } from './mapper'; +import { DeletionRequestRepo } from './deletion-request.repo'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { deletionRequestEntityFactory } from '../entity/testing/factory/deletion-request.entity.factory'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestRepo.name, () => { + let module: TestingModule; + let repo: DeletionRequestRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [DeletionRequestEntity], + }), + ], + providers: [DeletionRequestRepo, DeletionRequestMapper], + }).compile(); + + repo = module.get(DeletionRequestRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(DeletionRequestEntity); + }); + }); + + describe('create deletionRequest', () => { + describe('when deletionRequest is new', () => { + it('should create a new deletionRequest', async () => { + const domainObject: DeletionRequest = deletionRequestFactory.build(); + const deletionRequestId = domainObject.id; + await repo.create(domainObject); + + const result = await repo.findById(deletionRequestId); + + expect(result).toEqual(domainObject); + }); + }); + }); + + describe('findById', () => { + describe('when searching by Id', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + const expectedDeletionRequest = { + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedDeletionRequest, + }; + }; + + it('should find the deletionRequest', async () => { + const { entity, expectedDeletionRequest } = await setup(); + + const result: DeletionRequest = await repo.findById(entity.id); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedDeletionRequest)); + }); + }); + }); + + describe('findAllItemsToExecution', () => { + describe('when there is no deletionRequest for execution', () => { + it('should return empty array', async () => { + const result = await repo.findAllItemsToExecution(); + + expect(result).toEqual([]); + }); + }); + + describe('when there are deletionRequests for execution', () => { + const setup = async () => { + const dateInFuture = new Date(); + dateInFuture.setDate(dateInFuture.getDate() + 30); + const deletionRequestEntity1: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 7, 1), + deleteAfter: new Date(2023, 8, 1), + status: DeletionStatusModel.SUCCESS, + }); + const deletionRequestEntity2: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 7, 1), + deleteAfter: new Date(2023, 8, 1), + status: DeletionStatusModel.FAILED, + }); + const deletionRequestEntity3: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 8, 1), + deleteAfter: new Date(2023, 9, 1), + }); + const deletionRequestEntity4: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 9, 1), + deleteAfter: new Date(2023, 10, 1), + }); + const deletionRequestEntity5: DeletionRequestEntity = deletionRequestEntityFactory.build({ + deleteAfter: dateInFuture, + }); + + await em.persistAndFlush([ + deletionRequestEntity1, + deletionRequestEntity2, + deletionRequestEntity3, + deletionRequestEntity4, + deletionRequestEntity5, + ]); + em.clear(); + + const expectedArray = [ + { + id: deletionRequestEntity4.id, + targetRefDomain: deletionRequestEntity4.targetRefDomain, + deleteAfter: deletionRequestEntity4.deleteAfter, + targetRefId: deletionRequestEntity4.targetRefId, + status: deletionRequestEntity4.status, + createdAt: deletionRequestEntity4.createdAt, + updatedAt: deletionRequestEntity4.updatedAt, + }, + { + id: deletionRequestEntity3.id, + targetRefDomain: deletionRequestEntity3.targetRefDomain, + deleteAfter: deletionRequestEntity3.deleteAfter, + targetRefId: deletionRequestEntity3.targetRefId, + status: deletionRequestEntity3.status, + createdAt: deletionRequestEntity3.createdAt, + updatedAt: deletionRequestEntity3.updatedAt, + }, + { + id: deletionRequestEntity2.id, + targetRefDomain: deletionRequestEntity2.targetRefDomain, + deleteAfter: deletionRequestEntity2.deleteAfter, + targetRefId: deletionRequestEntity2.targetRefId, + status: deletionRequestEntity2.status, + createdAt: deletionRequestEntity2.createdAt, + updatedAt: deletionRequestEntity2.updatedAt, + }, + ]; + + return { deletionRequestEntity1, deletionRequestEntity5, expectedArray }; + }; + + it('should find deletionRequests with deleteAfter smaller then today and status with value registered or failed', async () => { + const { deletionRequestEntity1, deletionRequestEntity5, expectedArray } = await setup(); + + const results = await repo.findAllItemsToExecution(); + + expect(results.length).toEqual(3); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining(expectedArray[0]), + expect.objectContaining(expectedArray[1]), + expect.objectContaining(expectedArray[2]), + ]) + ); + + const result1: DeletionRequest = await repo.findById(deletionRequestEntity1.id); + + expect(result1.id).toEqual(deletionRequestEntity1.id); + + const result5: DeletionRequest = await repo.findById(deletionRequestEntity5.id); + + expect(result5.id).toEqual(deletionRequestEntity5.id); + }); + + it('should find deletionRequests to execute with limit = 2', async () => { + const { expectedArray } = await setup(); + + const results = await repo.findAllItemsToExecution(2); + + expect(results.length).toEqual(2); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + }); + }); + }); + + describe('update', () => { + describe('when updating deletionRequest', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + // Arrange expected DeletionRequestEntity after changing status + entity.status = DeletionStatusModel.SUCCESS; + const deletionRequestToUpdate = DeletionRequestMapper.mapToDO(entity); + + return { + entity, + deletionRequestToUpdate, + }; + }; + + it('should update the deletionRequest', async () => { + const { entity, deletionRequestToUpdate } = await setup(); + + await repo.update(deletionRequestToUpdate); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(entity.status); + }); + }); + }); + + describe('markDeletionRequestAsFailed', () => { + describe('when mark deletionRequest as failed', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + const result = await repo.markDeletionRequestAsFailed(entity.id); + + expect(result).toBe(true); + }); + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + await repo.markDeletionRequestAsFailed(entity.id); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(DeletionStatusModel.FAILED); + }); + }); + }); + + describe('markDeletionRequestAsExecuted', () => { + describe('when mark deletionRequest as executed', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + const result = await repo.markDeletionRequestAsExecuted(entity.id); + + expect(result).toBe(true); + }); + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + await repo.markDeletionRequestAsExecuted(entity.id); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(DeletionStatusModel.SUCCESS); + }); + }); + }); + + describe('deleteById', () => { + describe('when deleting deletionRequest exists', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + const deletionRequestId = entity.id; + await em.persistAndFlush(entity); + em.clear(); + + return { deletionRequestId }; + }; + + it('should delete the deletionRequest with deletionRequestId', async () => { + const { deletionRequestId } = await setup(); + + await repo.deleteById(deletionRequestId); + + expect(await em.findOne(DeletionRequestEntity, { id: deletionRequestId })).toBeNull(); + }); + + it('should return true', async () => { + const { deletionRequestId } = await setup(); + + const result: boolean = await repo.deleteById(deletionRequestId); + + expect(result).toEqual(true); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts new file mode 100644 index 00000000000..b24cf792f01 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts @@ -0,0 +1,86 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SortOrder } from '@shared/domain'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionRequestMapper } from './mapper/deletion-request.mapper'; +import { DeletionRequestScope } from './deletion-request-scope'; + +@Injectable() +export class DeletionRequestRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return DeletionRequestEntity; + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + const mapped: DeletionRequest = DeletionRequestMapper.mapToDO(deletionRequest); + + return mapped; + } + + async create(deletionRequest: DeletionRequest): Promise { + const deletionRequestEntity = DeletionRequestMapper.mapToEntity(deletionRequest); + this.em.persist(deletionRequestEntity); + await this.em.flush(); + } + + async findAllItemsToExecution(limit?: number): Promise { + const currentDate = new Date(); + const scope = new DeletionRequestScope().byDeleteAfter(currentDate).byStatus(); + const order = { createdAt: SortOrder.desc }; + + const [deletionRequestEntities] = await this.em.findAndCount(DeletionRequestEntity, scope.query, { + limit, + orderBy: order, + }); + + const mapped: DeletionRequest[] = deletionRequestEntities.map((entity) => DeletionRequestMapper.mapToDO(entity)); + + return mapped; + } + + async update(deletionRequest: DeletionRequest): Promise { + const deletionRequestEntity = DeletionRequestMapper.mapToEntity(deletionRequest); + const referencedEntity = this.em.getReference(DeletionRequestEntity, deletionRequestEntity.id); + + await this.em.persistAndFlush(referencedEntity); + } + + async markDeletionRequestAsExecuted(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + deletionRequest.executed(); + await this.em.persistAndFlush(deletionRequest); + + return true; + } + + async markDeletionRequestAsFailed(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + deletionRequest.failed(); + await this.em.persistAndFlush(deletionRequest); + + return true; + } + + async deleteById(deletionRequestId: EntityId): Promise { + const entity: DeletionRequestEntity | null = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + await this.em.removeAndFlush(entity); + + return true; + } +} diff --git a/apps/server/src/modules/deletion/repo/index.ts b/apps/server/src/modules/deletion/repo/index.ts new file mode 100644 index 00000000000..68860c00a79 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-log.repo'; +export * from './deletion-request.repo'; diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts new file mode 100644 index 00000000000..a5823f5ce32 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts @@ -0,0 +1,162 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { deletionLogEntityFactory } from '../../entity/testing/factory/deletion-log.entity.factory'; +import { DeletionLogMapper } from './deletion-log.mapper'; +import { DeletionLog } from '../../domain/deletion-log.do'; +import { deletionLogFactory } from '../../domain/testing/factory/deletion-log.factory'; +import { DeletionLogEntity } from '../../entity'; + +describe(DeletionLogMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + const setup = () => { + const entity = deletionLogEntityFactory.build(); + + const expectedDomainObject = new DeletionLog({ + id: entity.id, + domain: entity.domain, + operation: entity.operation, + deletionRequestId: entity.deletionRequestId?.toHexString(), + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + return { entity, expectedDomainObject }; + }; + it('should properly map the entity to the domain object', () => { + const { entity, expectedDomainObject } = setup(); + + const domainObject = DeletionLogMapper.mapToDO(entity); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToDOs', () => { + describe('When empty entities array is mapped for an empty domainObjects array', () => { + it('should return empty domain objects array for an empty entities array', () => { + const domainObjects = DeletionLogMapper.mapToDOs([]); + + expect(domainObjects).toEqual([]); + }); + }); + + describe('When entities array is mapped for domainObjects array', () => { + const setup = () => { + const entities = [deletionLogEntityFactory.build()]; + + const expectedDomainObjects = entities.map( + (entity) => + new DeletionLog({ + id: entity.id, + domain: entity.domain, + operation: entity.operation, + deletionRequestId: entity.deletionRequestId?.toHexString(), + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }) + ); + + return { entities, expectedDomainObjects }; + }; + it('should properly map the entities to the domain objects', () => { + const { entities, expectedDomainObjects } = setup(); + + const domainObjects = DeletionLogMapper.mapToDOs(entities); + + expect(domainObjects).toEqual(expectedDomainObjects); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const setup = () => { + const domainObject = deletionLogFactory.build(); + + const expectedEntities = new DeletionLogEntity({ + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + return { domainObject, expectedEntities }; + }; + + it('should properly map the domainObject to the entity', () => { + const { domainObject, expectedEntities } = setup(); + + const entities = DeletionLogMapper.mapToEntity(domainObject); + + expect(entities).toEqual(expectedEntities); + }); + }); + }); + + describe('mapToEntities', () => { + describe('When empty domainObjects array is mapped for an entities array', () => { + it('should return empty entities array for an empty domain objects array', () => { + const entities = DeletionLogMapper.mapToEntities([]); + + expect(entities).toEqual([]); + }); + }); + + describe('When domainObjects array is mapped for entities array', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const setup = () => { + const domainObjects = [deletionLogFactory.build()]; + + const expectedEntities = domainObjects.map( + (domainObject) => + new DeletionLogEntity({ + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }) + ); + + return { domainObjects, expectedEntities }; + }; + + it('should properly map the domainObjects to the entities', () => { + const { domainObjects, expectedEntities } = setup(); + + const entities = DeletionLogMapper.mapToEntities(domainObjects); + + expect(entities).toEqual(expectedEntities); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts new file mode 100644 index 00000000000..820cd9d87c0 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts @@ -0,0 +1,39 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogEntity } from '../../entity/deletion-log.entity'; +import { DeletionLog } from '../../domain/deletion-log.do'; + +export class DeletionLogMapper { + static mapToDO(entity: DeletionLogEntity): DeletionLog { + return new DeletionLog({ + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId?.toHexString(), + }); + } + + static mapToEntity(domainObject: DeletionLog): DeletionLogEntity { + return new DeletionLogEntity({ + id: domainObject.id, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + domain: domainObject.domain, + operation: domainObject.operation, + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + }); + } + + static mapToDOs(entities: DeletionLogEntity[]): DeletionLog[] { + return entities.map((entity) => this.mapToDO(entity)); + } + + static mapToEntities(domainObjects: DeletionLog[]): DeletionLogEntity[] { + return domainObjects.map((domainObject) => this.mapToEntity(domainObject)); + } +} diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts new file mode 100644 index 00000000000..4e880aab54e --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts @@ -0,0 +1,71 @@ +import { DeletionRequest } from '../../domain/deletion-request.do'; +import { deletionRequestFactory } from '../../domain/testing/factory/deletion-request.factory'; +import { DeletionRequestEntity } from '../../entity'; +import { deletionRequestEntityFactory } from '../../entity/testing/factory/deletion-request.entity.factory'; +import { DeletionRequestMapper } from './deletion-request.mapper'; + +describe(DeletionRequestMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + const setup = () => { + const entity = deletionRequestEntityFactory.build(); + + const expectedDomainObject = new DeletionRequest({ + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + return { entity, expectedDomainObject }; + }; + + it('should properly map the entity to the domain object', () => { + const { entity, expectedDomainObject } = setup(); + + const domainObject = DeletionRequestMapper.mapToDO(entity); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + const setup = () => { + const domainObject = deletionRequestFactory.build(); + + const expectedEntity = new DeletionRequestEntity({ + id: domainObject.id, + targetRefDomain: domainObject.targetRefDomain, + deleteAfter: domainObject.deleteAfter, + targetRefId: domainObject.targetRefId, + status: domainObject.status, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + return { domainObject, expectedEntity }; + }; + + it('should properly map the domainObject to the entity', () => { + const { domainObject, expectedEntity } = setup(); + + const entity = DeletionRequestMapper.mapToEntity(domainObject); + + expect(entity).toEqual(expectedEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts new file mode 100644 index 00000000000..fd6c273011f --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts @@ -0,0 +1,28 @@ +import { DeletionRequest } from '../../domain/deletion-request.do'; +import { DeletionRequestEntity } from '../../entity'; + +export class DeletionRequestMapper { + static mapToDO(entity: DeletionRequestEntity): DeletionRequest { + return new DeletionRequest({ + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + }); + } + + static mapToEntity(domainObject: DeletionRequest): DeletionRequestEntity { + return new DeletionRequestEntity({ + id: domainObject.id, + targetRefDomain: domainObject.targetRefDomain, + deleteAfter: domainObject.deleteAfter, + targetRefId: domainObject.targetRefId, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + status: domainObject.status, + }); + } +} diff --git a/apps/server/src/modules/deletion/repo/mapper/index.ts b/apps/server/src/modules/deletion/repo/mapper/index.ts new file mode 100644 index 00000000000..0407135b228 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-request.mapper'; +export * from './deletion-log.mapper'; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts new file mode 100644 index 00000000000..21522e5e924 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts @@ -0,0 +1,110 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogRepo } from '../repo'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from './deletion-log.service'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; + +describe(DeletionLogService.name, () => { + let module: TestingModule; + let service: DeletionLogService; + let deletionLogRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionLogService, + { + provide: DeletionLogRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DeletionLogService); + deletionLogRepo = module.get(DeletionLogRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + const deletionRequestId = '653e4833cc39e5907a1e18d2'; + const domain = DeletionDomainModel.USER; + const operation = DeletionOperationModel.DELETE; + const modifiedCount = 0; + const deletedCount = 1; + + return { deletionRequestId, domain, operation, modifiedCount, deletedCount }; + }; + + it('should call deletionRequestRepo.create', async () => { + const { deletionRequestId, domain, operation, modifiedCount, deletedCount } = setup(); + + await service.createDeletionLog(deletionRequestId, domain, operation, modifiedCount, deletedCount); + + expect(deletionLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + deletionRequestId, + domain, + operation, + modifiedCount, + deletedCount, + }) + ); + }); + }); + }); + + describe('findByDeletionRequestId', () => { + describe('when finding all logs for deletionRequestId', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + const deletionLog1 = deletionLogFactory.build({ deletionRequestId }); + const deletionLog2 = deletionLogFactory.build({ + deletionRequestId, + domain: DeletionDomainModel.PSEUDONYMS, + }); + const deletionLogs = [deletionLog1, deletionLog2]; + + deletionLogRepo.findAllByDeletionRequestId.mockResolvedValue(deletionLogs); + + return { deletionRequestId, deletionLogs }; + }; + + it('should call deletionLogRepo.findAllByDeletionRequestId', async () => { + const { deletionRequestId } = setup(); + await service.findByDeletionRequestId(deletionRequestId); + + expect(deletionLogRepo.findAllByDeletionRequestId).toBeCalledWith(deletionRequestId); + }); + + it('should return array of two deletionLogs with deletionRequestId', async () => { + const { deletionRequestId, deletionLogs } = setup(); + const result = await service.findByDeletionRequestId(deletionRequestId); + + expect(result).toHaveLength(2); + expect(result).toEqual(deletionLogs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.ts b/apps/server/src/modules/deletion/services/deletion-log.service.ts new file mode 100644 index 00000000000..937d422ebb3 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-log.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogRepo } from '../repo'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; + +@Injectable() +export class DeletionLogService { + constructor(private readonly deletionLogRepo: DeletionLogRepo) {} + + async createDeletionLog( + deletionRequestId: EntityId, + domain: DeletionDomainModel, + operation: DeletionOperationModel, + modifiedCount: number, + deletedCount: number + ): Promise { + const newDeletionLog = new DeletionLog({ + id: new ObjectId().toHexString(), + domain, + deletionRequestId, + operation, + modifiedCount, + deletedCount, + }); + + await this.deletionLogRepo.create(newDeletionLog); + } + + async findByDeletionRequestId(deletionRequestId: EntityId): Promise { + const deletionLogs: DeletionLog[] = await this.deletionLogRepo.findAllByDeletionRequestId(deletionRequestId); + + return deletionLogs; + } +} diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts new file mode 100644 index 00000000000..fcccfc433db --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts @@ -0,0 +1,200 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { DeletionRequestService } from './deletion-request.service'; +import { DeletionRequestRepo } from '../repo'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestService.name, () => { + let module: TestingModule; + let service: DeletionRequestService; + let deletionRequestRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionRequestService, + { + provide: DeletionRequestRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DeletionRequestService); + deletionRequestRepo = module.get(DeletionRequestRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + const targetRefId = '653e4833cc39e5907a1e18d2'; + const targetRefDomain = DeletionDomainModel.USER; + + return { targetRefId, targetRefDomain }; + }; + + it('should call deletionRequestRepo.create', async () => { + const { targetRefId, targetRefDomain } = setup(); + + await service.createDeletionRequest(targetRefId, targetRefDomain); + + expect(deletionRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + targetRefDomain, + deleteAfter: expect.any(Date), + targetRefId, + status: DeletionStatusModel.REGISTERED, + }) + ); + }); + }); + }); + + describe('findById', () => { + describe('when finding by deletionRequestId', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + const deletionRequest = deletionRequestFactory.build({ id: deletionRequestId }); + + deletionRequestRepo.findById.mockResolvedValue(deletionRequest); + + return { deletionRequestId, deletionRequest }; + }; + + it('should call deletionRequestRepo.findById', async () => { + const { deletionRequestId } = setup(); + + await service.findById(deletionRequestId); + + expect(deletionRequestRepo.findById).toBeCalledWith(deletionRequestId); + }); + + it('should return deletionRequest', async () => { + const { deletionRequestId, deletionRequest } = setup(); + + const result = await service.findById(deletionRequestId); + + expect(result).toEqual(deletionRequest); + }); + }); + }); + + describe('findAllItemsToExecute', () => { + describe('when finding all deletionRequests for execution', () => { + const setup = () => { + const dateInPast = new Date(); + dateInPast.setDate(dateInPast.getDate() - 1); + const deletionRequest1 = deletionRequestFactory.build({ deleteAfter: dateInPast }); + const deletionRequest2 = deletionRequestFactory.build({ deleteAfter: dateInPast }); + + deletionRequestRepo.findAllItemsToExecution.mockResolvedValue([deletionRequest1, deletionRequest2]); + + const deletionRequests = [deletionRequest1, deletionRequest2]; + return { deletionRequests }; + }; + + it('should call deletionRequestRepo.findAllItemsByDeletionDate', async () => { + await service.findAllItemsToExecute(); + + expect(deletionRequestRepo.findAllItemsToExecution).toBeCalled(); + }); + + it('should return array of two deletionRequests to execute', async () => { + const { deletionRequests } = setup(); + const result = await service.findAllItemsToExecute(); + + expect(result).toHaveLength(2); + expect(result).toEqual(deletionRequests); + }); + }); + }); + + describe('update', () => { + describe('when updating deletionRequest', () => { + const setup = () => { + const deletionRequest = deletionRequestFactory.buildWithId(); + + return { deletionRequest }; + }; + + it('should call deletionRequestRepo.update', async () => { + const { deletionRequest } = setup(); + await service.update(deletionRequest); + + expect(deletionRequestRepo.update).toBeCalledWith(deletionRequest); + }); + }); + }); + + describe('markDeletionRequestAsExecuted', () => { + describe('when mark deletionRequest as executed', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.markDeletionRequestAsExecuted', async () => { + const { deletionRequestId } = setup(); + await service.markDeletionRequestAsExecuted(deletionRequestId); + + expect(deletionRequestRepo.markDeletionRequestAsExecuted).toBeCalledWith(deletionRequestId); + }); + }); + }); + + describe('markDeletionRequestAsFailed', () => { + describe('when mark deletionRequest as failed', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.markDeletionRequestAsExecuted', async () => { + const { deletionRequestId } = setup(); + await service.markDeletionRequestAsFailed(deletionRequestId); + + expect(deletionRequestRepo.markDeletionRequestAsFailed).toBeCalledWith(deletionRequestId); + }); + }); + }); + + describe('deleteById', () => { + describe('when deleting deletionRequest', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.findAllItemsByDeletionDate', async () => { + const { deletionRequestId } = setup(); + await service.deleteById(deletionRequestId); + + expect(deletionRequestRepo.deleteById).toBeCalledWith(deletionRequestId); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.ts b/apps/server/src/modules/deletion/services/deletion-request.service.ts new file mode 100644 index 00000000000..82b65521d68 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-request.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequestRepo } from '../repo/deletion-request.repo'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +@Injectable() +export class DeletionRequestService { + constructor(private readonly deletionRequestRepo: DeletionRequestRepo) {} + + async createDeletionRequest( + targetRefId: EntityId, + targetRefDomain: DeletionDomainModel, + deleteInMinutes = 43200 + ): Promise<{ requestId: EntityId; deletionPlannedAt: Date }> { + const dateOfDeletion = new Date(); + dateOfDeletion.setMinutes(dateOfDeletion.getMinutes() + deleteInMinutes); + + const newDeletionRequest = new DeletionRequest({ + id: new ObjectId().toHexString(), + targetRefDomain, + deleteAfter: dateOfDeletion, + targetRefId, + status: DeletionStatusModel.REGISTERED, + }); + + await this.deletionRequestRepo.create(newDeletionRequest); + + return { requestId: newDeletionRequest.id, deletionPlannedAt: newDeletionRequest.deleteAfter }; + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequest = await this.deletionRequestRepo.findById(deletionRequestId); + + return deletionRequest; + } + + async findAllItemsToExecute(limit?: number): Promise { + const itemsToDelete: DeletionRequest[] = await this.deletionRequestRepo.findAllItemsToExecution(limit); + + return itemsToDelete; + } + + async update(deletionRequestToUpdate: DeletionRequest): Promise { + await this.deletionRequestRepo.update(deletionRequestToUpdate); + } + + async markDeletionRequestAsExecuted(deletionRequestId: EntityId): Promise { + return this.deletionRequestRepo.markDeletionRequestAsExecuted(deletionRequestId); + } + + async markDeletionRequestAsFailed(deletionRequestId: EntityId): Promise { + return this.deletionRequestRepo.markDeletionRequestAsFailed(deletionRequestId); + } + + async deleteById(deletionRequestId: EntityId): Promise { + await this.deletionRequestRepo.deleteById(deletionRequestId); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts new file mode 100644 index 00000000000..9661354718c --- /dev/null +++ b/apps/server/src/modules/deletion/services/index.ts @@ -0,0 +1 @@ +export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts new file mode 100644 index 00000000000..c2952f40f59 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts @@ -0,0 +1,22 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatisticBuilder } from './deletion-log-statistic.builder'; + +describe(DeletionLogStatisticBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionLogStatistic with all attributes', () => { + // Arrange + const domain = DeletionDomainModel.PSEUDONYMS; + const modifiedCount = 0; + const deletedCount = 2; + + const result = DeletionLogStatisticBuilder.build(domain, modifiedCount, deletedCount); + + // Assert + expect(result.domain).toEqual(domain); + expect(result.modifiedCount).toEqual(modifiedCount); + expect(result.deletedCount).toEqual(deletedCount); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts new file mode 100644 index 00000000000..a562505b885 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts @@ -0,0 +1,10 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatistic } from '../interface'; + +export class DeletionLogStatisticBuilder { + static build(domain: DeletionDomainModel, modifiedCount?: number, deletedCount?: number): DeletionLogStatistic { + const deletionLogStatistic = { domain, modifiedCount, deletedCount }; + + return deletionLogStatistic; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts new file mode 100644 index 00000000000..b317a4b2221 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts @@ -0,0 +1,28 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatisticBuilder } from './deletion-log-statistic.builder'; +import { DeletionRequestLogBuilder } from './deletion-request-log.builder'; +import { DeletionTargetRefBuilder } from './deletion-target-ref.builder'; + +describe(DeletionRequestLogBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionRequestLog with all attributes', () => { + // Arrange + const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefId = '653e4833cc39e5907a1e18d2'; + const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); + const deletionPlannedAt = new Date(); + const modifiedCount = 0; + const deletedCount = 2; + const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, modifiedCount, deletedCount)]; + + const result = DeletionRequestLogBuilder.build(targetRef, deletionPlannedAt, statistics); + + // Assert + expect(result.targetRef).toEqual(targetRef); + expect(result.deletionPlannedAt).toEqual(deletionPlannedAt); + expect(result.statistics).toEqual(statistics); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts new file mode 100644 index 00000000000..8247acf6776 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts @@ -0,0 +1,13 @@ +import { DeletionLogStatistic, DeletionRequestLog, DeletionTargetRef } from '../interface'; + +export class DeletionRequestLogBuilder { + static build( + targetRef: DeletionTargetRef, + deletionPlannedAt: Date, + statistics?: DeletionLogStatistic[] + ): DeletionRequestLog { + const deletionRequestLog = { targetRef, deletionPlannedAt, statistics }; + + return deletionRequestLog; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts new file mode 100644 index 00000000000..2fb4ae440a7 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts @@ -0,0 +1,20 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionTargetRefBuilder } from './deletion-target-ref.builder'; + +describe(DeletionTargetRefBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionTargetRef with all attributes', () => { + // Arrange + const domain = DeletionDomainModel.PSEUDONYMS; + const refId = '653e4833cc39e5907a1e18d2'; + + const result = DeletionTargetRefBuilder.build(domain, refId); + + // Assert + expect(result.targetRefDomain).toEqual(domain); + expect(result.targetRefId).toEqual(refId); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts new file mode 100644 index 00000000000..91f3385a9aa --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts @@ -0,0 +1,11 @@ +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionTargetRef } from '../interface'; + +export class DeletionTargetRefBuilder { + static build(targetRefDomain: DeletionDomainModel, targetRefId: EntityId): DeletionTargetRef { + const deletionTargetRef = { targetRefDomain, targetRefId }; + + return deletionTargetRef; + } +} diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts new file mode 100644 index 00000000000..34c34e302f5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -0,0 +1,511 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { AccountService } from '@modules/account/services'; +import { ClassService } from '@modules/class'; +import { CourseGroupService, CourseService } from '@modules/learnroom/service'; +import { FilesService } from '@modules/files/service'; +import { LessonService } from '@modules/lesson/service'; +import { PseudonymService } from '@modules/pseudonym'; +import { TeamService } from '@modules/teams'; +import { UserService } from '@modules/user'; +import { RocketChatService } from '@modules/rocketchat'; +import { rocketChatUserFactory } from '@modules/rocketchat-user/domain/testing'; +import { RocketChatUser, RocketChatUserService } from '@modules/rocketchat-user'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from '../services/deletion-log.service'; +import { DeletionRequestService } from '../services'; +import { DeletionRequestUc } from './deletion-request.uc'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; +import { DeletionRequestLog, DeletionRequestProps } from './interface'; + +describe(DeletionRequestUc.name, () => { + let module: TestingModule; + let uc: DeletionRequestUc; + let deletionRequestService: DeepMocked; + let deletionLogService: DeepMocked; + let accountService: DeepMocked; + let classService: DeepMocked; + let courseGroupService: DeepMocked; + let courseService: DeepMocked; + let filesService: DeepMocked; + let lessonService: DeepMocked; + let pseudonymService: DeepMocked; + let teamService: DeepMocked; + let userService: DeepMocked; + let rocketChatUserService: DeepMocked; + let rocketChatService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionRequestUc, + { + provide: DeletionRequestService, + useValue: createMock(), + }, + { + provide: DeletionLogService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: CourseGroupService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: FilesService, + useValue: createMock(), + }, + { + provide: LessonService, + useValue: createMock(), + }, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: TeamService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RocketChatUserService, + useValue: createMock(), + }, + { + provide: RocketChatService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionRequestUc); + deletionRequestService = module.get(DeletionRequestService); + deletionLogService = module.get(DeletionLogService); + accountService = module.get(AccountService); + classService = module.get(ClassService); + courseGroupService = module.get(CourseGroupService); + courseService = module.get(CourseService); + filesService = module.get(FilesService); + lessonService = module.get(LessonService); + pseudonymService = module.get(PseudonymService); + teamService = module.get(TeamService); + userService = module.get(UserService); + rocketChatUserService = module.get(RocketChatUserService); + rocketChatService = module.get(RocketChatService); + await setupEntities(); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToCreate: DeletionRequestProps = { + targetRef: { + targetRefDoamin: DeletionDomainModel.USER, + targetRefId: '653e4833cc39e5907a1e18d2', + }, + deleteInMinutes: 1440, + }; + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequestToCreate, + deletionRequest, + }; + }; + + it('should call the service to create the deletionRequest', async () => { + const { deletionRequestToCreate } = setup(); + + await uc.createDeletionRequest(deletionRequestToCreate); + + expect(deletionRequestService.createDeletionRequest).toHaveBeenCalledWith( + deletionRequestToCreate.targetRef.targetRefId, + deletionRequestToCreate.targetRef.targetRefDoamin, + deletionRequestToCreate.deleteInMinutes + ); + }); + + it('should return the deletionRequestID and deletionPlannedAt', async () => { + const { deletionRequestToCreate, deletionRequest } = setup(); + + deletionRequestService.createDeletionRequest.mockResolvedValueOnce({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + + const result = await uc.createDeletionRequest(deletionRequestToCreate); + + expect(result).toEqual({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + }); + }); + }); + + describe('executeDeletionRequests', () => { + describe('when executing deletionRequests', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ + userId: deletionRequestToExecute.targetRefId, + }); + + classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); + courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); + courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); + filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); + filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); + pseudonymService.deleteByUserId.mockResolvedValueOnce(2); + teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); + userService.deleteUser.mockResolvedValueOnce(1); + rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); + + return { + deletionRequestToExecute, + rocketChatUser, + }; + }; + + it('should call deletionRequestService.findAllItemsToExecute', async () => { + await uc.executeDeletionRequests(); + + expect(deletionRequestService.findAllItemsToExecute).toHaveBeenCalled(); + }); + + it('should call deletionRequestService.markDeletionRequestAsExecuted to update status of deletionRequests', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsExecuted).toHaveBeenCalledWith(deletionRequestToExecute.id); + }); + + it('should call accountService.deleteByUserId to delete user data in account module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(accountService.deleteByUserId).toHaveBeenCalled(); + }); + + it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(classService.deleteUserDataFromClasses).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call courseGroupService.deleteUserDataFromCourseGroup to delete user data in courseGroup module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(courseGroupService.deleteUserDataFromCourseGroup).toHaveBeenCalledWith( + deletionRequestToExecute.targetRefId + ); + }); + + it('should call courseService.deleteUserDataFromCourse to delete user data in course module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(courseService.deleteUserDataFromCourse).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call filesService.markFilesOwnedByUserForDeletion to mark users files to delete in file module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(filesService.markFilesOwnedByUserForDeletion).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call filesService.removeUserPermissionsToAnyFiles to remove users permissions to any files in file module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(filesService.removeUserPermissionsToAnyFiles).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call lessonService.deleteUserDataFromLessons to delete users data in lesson module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(lessonService.deleteUserDataFromLessons).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call pseudonymService.deleteByUserId to delete users data in pseudonym module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(pseudonymService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call teamService.deleteUserDataFromTeams to delete users data in teams module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(teamService.deleteUserDataFromTeams).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call userService.deleteUsers to delete user in user module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(userService.deleteUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call rocketChatUserService.findByUserId to find rocketChatUser in rocketChatUser module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(rocketChatUserService.findByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call rocketChatUserService.deleteByUserId to delete rocketChatUser in rocketChatUser module', async () => { + const { deletionRequestToExecute, rocketChatUser } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); + + await uc.executeDeletionRequests(); + + expect(rocketChatUserService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call rocketChatService.deleteUser to delete rocketChatUser in rocketChat external module', async () => { + const { deletionRequestToExecute, rocketChatUser } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); + + await uc.executeDeletionRequests(); + + expect(rocketChatService.deleteUser).toHaveBeenCalledWith(rocketChatUser.username); + }); + + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(9); + }); + }); + + describe('when an error occurred', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + + classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); + courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); + courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); + filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); + filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); + pseudonymService.deleteByUserId.mockResolvedValueOnce(2); + teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); + userService.deleteUser.mockRejectedValueOnce(new Error()); + + return { + deletionRequestToExecute, + }; + }; + + it('should throw an arror', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsFailed).toHaveBeenCalledWith(deletionRequestToExecute.id); + }); + }); + }); + + describe('findById', () => { + describe('when searching for logs for deletionRequest which was executed', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestExecuted = deletionRequestFactory.build({ status: DeletionStatusModel.SUCCESS }); + const deletionLogExecuted1 = deletionLogFactory.build({ deletionRequestId: deletionRequestExecuted.id }); + const deletionLogExecuted2 = deletionLogFactory.build({ + deletionRequestId: deletionRequestExecuted.id, + domain: DeletionDomainModel.ACCOUNT, + modifiedCount: 0, + deletedCount: 1, + }); + + const executedDeletionRequestSummary: DeletionRequestLog = { + targetRef: { + targetRefDomain: deletionRequestExecuted.targetRefDomain, + targetRefId: deletionRequestExecuted.targetRefId, + }, + deletionPlannedAt: deletionRequestExecuted.deleteAfter, + statistics: [ + { + domain: deletionLogExecuted1.domain, + modifiedCount: deletionLogExecuted1.modifiedCount, + deletedCount: deletionLogExecuted1.deletedCount, + }, + { + domain: deletionLogExecuted2.domain, + modifiedCount: deletionLogExecuted2.modifiedCount, + deletedCount: deletionLogExecuted2.deletedCount, + }, + ], + }; + + return { + deletionRequestExecuted, + executedDeletionRequestSummary, + deletionLogExecuted1, + deletionLogExecuted2, + }; + }; + + it('should call to deletionRequestService and deletionLogService', async () => { + const { deletionRequestExecuted } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + + await uc.findById(deletionRequestExecuted.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequestExecuted.id); + expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequestExecuted.id); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequestExecuted, deletionLogExecuted1, deletionLogExecuted2, executedDeletionRequestSummary } = + setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + deletionLogService.findByDeletionRequestId.mockResolvedValueOnce([deletionLogExecuted1, deletionLogExecuted2]); + + const result = await uc.findById(deletionRequestExecuted.id); + + expect(result).toEqual(executedDeletionRequestSummary); + }); + }); + + describe('when searching for logs for deletionRequest which was not executed', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequest = deletionRequestFactory.build(); + const notExecutedDeletionRequestSummary: DeletionRequestLog = { + targetRef: { + targetRefDomain: deletionRequest.targetRefDomain, + targetRefId: deletionRequest.targetRefId, + }, + deletionPlannedAt: deletionRequest.deleteAfter, + }; + + return { + deletionRequest, + notExecutedDeletionRequestSummary, + }; + }; + + it('should call to deletionRequestService', async () => { + const { deletionRequest } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + await uc.findById(deletionRequest.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequest.id); + expect(deletionLogService.findByDeletionRequestId).not.toHaveBeenCalled(); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequest, notExecutedDeletionRequestSummary } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + const result = await uc.findById(deletionRequest.id); + + expect(result).toEqual(notExecutedDeletionRequestSummary); + }); + }); + }); + + describe('deleteDeletionRequestById', () => { + describe('when deleting a deletionRequestId', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequest, + }; + }; + + it('should call the service deletionRequestService.deleteById', async () => { + const { deletionRequest } = setup(); + + await uc.deleteDeletionRequestById(deletionRequest.id); + + expect(deletionRequestService.deleteById).toHaveBeenCalledWith(deletionRequest.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts new file mode 100644 index 00000000000..abea56fda96 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -0,0 +1,225 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { PseudonymService } from '@modules/pseudonym'; +import { UserService } from '@modules/user'; +import { TeamService } from '@modules/teams'; +import { ClassService } from '@modules/class'; +import { LessonService } from '@modules/lesson/service'; +import { CourseGroupService, CourseService } from '@modules/learnroom/service'; +import { FilesService } from '@modules/files/service'; +import { AccountService } from '@modules/account/services'; +import { RocketChatUserService } from '@modules/rocketchat-user'; +import { RocketChatService } from '@modules/rocketchat'; +import { DeletionRequestService } from '../services/deletion-request.service'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from '../services/deletion-log.service'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { + DeletionRequestProps, + DeletionRequestLog, + DeletionLogStatistic, + DeletionRequestCreateAnswer, +} from './interface/interfaces'; +import { DeletionLogStatisticBuilder } from './builder/deletion-log-statistic.builder'; +import { DeletionRequestLogBuilder } from './builder/deletion-request-log.builder'; +import { DeletionTargetRefBuilder } from './builder/deletion-target-ref.builder'; + +@Injectable() +export class DeletionRequestUc { + constructor( + private readonly deletionRequestService: DeletionRequestService, + private readonly deletionLogService: DeletionLogService, + private readonly accountService: AccountService, + private readonly classService: ClassService, + private readonly courseGroupService: CourseGroupService, + private readonly courseService: CourseService, + private readonly filesService: FilesService, + private readonly lessonService: LessonService, + private readonly pseudonymService: PseudonymService, + private readonly teamService: TeamService, + private readonly userService: UserService, + private readonly rocketChatUserService: RocketChatUserService, + private readonly rocketChatService: RocketChatService + ) {} + + async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { + const result = await this.deletionRequestService.createDeletionRequest( + deletionRequest.targetRef.targetRefId, + deletionRequest.targetRef.targetRefDoamin, + deletionRequest.deleteInMinutes + ); + + return result; + } + + async executeDeletionRequests(limit?: number): Promise { + const deletionRequestToExecution: DeletionRequest[] = await this.deletionRequestService.findAllItemsToExecute( + limit + ); + + for (const req of deletionRequestToExecution) { + // eslint-disable-next-line no-await-in-loop + await this.executeDeletionRequest(req); + } + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequest = await this.deletionRequestService.findById(deletionRequestId); + let response: DeletionRequestLog = DeletionRequestLogBuilder.build( + DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId), + deletionRequest.deleteAfter + ); + + if (deletionRequest.status === DeletionStatusModel.SUCCESS) { + const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); + const deletionLogStatistic: DeletionLogStatistic[] = deletionLog.map((log) => + DeletionLogStatisticBuilder.build(log.domain, log.modifiedCount, log.deletedCount) + ); + response = { ...response, statistics: deletionLogStatistic }; + } + + return response; + } + + async deleteDeletionRequestById(deletionRequestId: EntityId): Promise { + await this.deletionRequestService.deleteById(deletionRequestId); + } + + private async executeDeletionRequest(deletionRequest: DeletionRequest): Promise { + try { + await Promise.all([ + this.removeAccount(deletionRequest), + this.removeUserFromClasses(deletionRequest), + this.removeUserFromCourseGroup(deletionRequest), + this.removeUserFromCourse(deletionRequest), + this.removeUsersFilesAndPermissions(deletionRequest), + this.removeUserFromLessons(deletionRequest), + this.removeUsersPseudonyms(deletionRequest), + this.removeUserFromTeams(deletionRequest), + this.removeUser(deletionRequest), + this.removeUserFromRocketChat(deletionRequest), + ]); + await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); + } catch (error) { + await this.deletionRequestService.markDeletionRequestAsFailed(deletionRequest.id); + } + } + + private async logDeletion( + deletionRequest: DeletionRequest, + domainModel: DeletionDomainModel, + operationModel: DeletionOperationModel, + updatedCount: number, + deletedCount: number + ): Promise { + if (updatedCount > 0 || deletedCount > 0) { + await this.deletionLogService.createDeletionLog( + deletionRequest.id, + domainModel, + operationModel, + updatedCount, + deletedCount + ); + } + } + + private async removeAccount(deletionRequest: DeletionRequest) { + await this.accountService.deleteByUserId(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); + } + + private async removeUserFromClasses(deletionRequest: DeletionRequest) { + const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.CLASS, + DeletionOperationModel.UPDATE, + classesUpdated, + 0 + ); + } + + private async removeUserFromCourseGroup(deletionRequest: DeletionRequest) { + const courseGroupUpdated: number = await this.courseGroupService.deleteUserDataFromCourseGroup( + deletionRequest.targetRefId + ); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.COURSEGROUP, + DeletionOperationModel.UPDATE, + courseGroupUpdated, + 0 + ); + } + + private async removeUserFromCourse(deletionRequest: DeletionRequest) { + const courseUpdated: number = await this.courseService.deleteUserDataFromCourse(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.COURSE, + DeletionOperationModel.UPDATE, + courseUpdated, + 0 + ); + } + + private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { + const filesDeleted: number = await this.filesService.markFilesOwnedByUserForDeletion(deletionRequest.targetRefId); + const filePermissionsUpdated: number = await this.filesService.removeUserPermissionsToAnyFiles( + deletionRequest.targetRefId + ); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.FILE, + DeletionOperationModel.UPDATE, + filesDeleted + filePermissionsUpdated, + 0 + ); + } + + private async removeUserFromLessons(deletionRequest: DeletionRequest) { + const lessonsUpdated: number = await this.lessonService.deleteUserDataFromLessons(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.LESSONS, + DeletionOperationModel.UPDATE, + lessonsUpdated, + 0 + ); + } + + private async removeUsersPseudonyms(deletionRequest: DeletionRequest) { + const pseudonymDeleted: number = await this.pseudonymService.deleteByUserId(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.PSEUDONYMS, + DeletionOperationModel.DELETE, + 0, + pseudonymDeleted + ); + } + + private async removeUserFromTeams(deletionRequest: DeletionRequest) { + const teamsUpdated: number = await this.teamService.deleteUserDataFromTeams(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); + } + + private async removeUser(deletionRequest: DeletionRequest) { + const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); + } + + private async removeUserFromRocketChat(deletionRequest: DeletionRequest): Promise { + const rocketChatUser = await this.rocketChatUserService.findByUserId(deletionRequest.targetRefId); + + const [, rocketChatUserDeleted] = await Promise.all([ + this.rocketChatService.deleteUser(rocketChatUser.username), + this.rocketChatUserService.deleteByUserId(rocketChatUser.userId), + ]); + + return rocketChatUserDeleted; + } +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts new file mode 100644 index 00000000000..95786098275 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -0,0 +1 @@ +export * from './interfaces'; diff --git a/apps/server/src/modules/deletion/uc/interface/interfaces.ts b/apps/server/src/modules/deletion/uc/interface/interfaces.ts new file mode 100644 index 00000000000..47f4d887735 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/interfaces.ts @@ -0,0 +1,29 @@ +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; + +export interface DeletionTargetRef { + targetRefDomain: DeletionDomainModel; + targetRefId: EntityId; +} + +export interface DeletionRequestLog { + targetRef: DeletionTargetRef; + deletionPlannedAt: Date; + statistics?: DeletionLogStatistic[]; +} + +export interface DeletionLogStatistic { + domain: DeletionDomainModel; + modifiedCount?: number; + deletedCount?: number; +} + +export interface DeletionRequestProps { + targetRef: { targetRefDoamin: DeletionDomainModel; targetRefId: EntityId }; + deleteInMinutes?: number; +} + +export interface DeletionRequestCreateAnswer { + requestId: EntityId; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/files-storage-client/dto/file.dto.spec.ts b/apps/server/src/modules/files-storage-client/dto/file.dto.spec.ts index 509eee7f49f..0017e0aaa42 100644 --- a/apps/server/src/modules/files-storage-client/dto/file.dto.spec.ts +++ b/apps/server/src/modules/files-storage-client/dto/file.dto.spec.ts @@ -1,4 +1,4 @@ -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { FileDto } from './file.dto'; describe('FileDto', () => { diff --git a/apps/server/src/modules/files-storage-client/dto/file.dto.ts b/apps/server/src/modules/files-storage-client/dto/file.dto.ts index 5ac6e76181f..38d6daf4c3b 100644 --- a/apps/server/src/modules/files-storage-client/dto/file.dto.ts +++ b/apps/server/src/modules/files-storage-client/dto/file.dto.ts @@ -1,5 +1,5 @@ import { EntityId } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { IFileDomainObjectProps } from '../interfaces'; export class FileDto { diff --git a/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts b/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts index 35513f27b02..f302e53e9a0 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts @@ -1,5 +1,5 @@ import { EntityId } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; export interface IFileDomainObjectProps { id: EntityId; diff --git a/apps/server/src/modules/files-storage-client/interfaces/file-request-info.ts b/apps/server/src/modules/files-storage-client/interfaces/file-request-info.ts index d45df0aef3d..12a7898d9cf 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/file-request-info.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/file-request-info.ts @@ -1,5 +1,5 @@ import { EntityId } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; export interface IFileRequestInfo { schoolId: EntityId; diff --git a/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.spec.ts b/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.spec.ts index 9cb8f09553f..ccce67a089a 100644 --- a/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.spec.ts +++ b/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { lessonFactory, setupEntities, taskFactory } from '@shared/testing'; import { CopyFilesOfParentParamBuilder } from './copy-files-of-parent-param.builder'; import { FileParamBuilder } from './files-storage-param.builder'; diff --git a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.spec.ts b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.spec.ts index 2a7d100ca86..f48d66dce48 100644 --- a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.spec.ts +++ b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.spec.ts @@ -1,4 +1,4 @@ -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { ICopyFileDomainObjectProps, IFileDomainObjectProps } from '../interfaces'; import { FilesStorageClientMapper } from './files-storage-client.mapper'; diff --git a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts index 2c73ec3824e..233e47fd4c8 100644 --- a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts +++ b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts @@ -1,5 +1,5 @@ import { LessonEntity, Submission, Task } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { CopyFileDto, FileDto } from '../dto'; import { EntitiesWithFiles, ICopyFileDomainObjectProps, IFileDomainObjectProps } from '../interfaces'; diff --git a/apps/server/src/modules/files-storage-client/mapper/files-storage-param.builder.spec.ts b/apps/server/src/modules/files-storage-client/mapper/files-storage-param.builder.spec.ts index 72a639620f1..23ce4f7e175 100644 --- a/apps/server/src/modules/files-storage-client/mapper/files-storage-param.builder.spec.ts +++ b/apps/server/src/modules/files-storage-client/mapper/files-storage-param.builder.spec.ts @@ -1,4 +1,4 @@ -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { lessonFactory, setupEntities, taskFactory } from '@shared/testing'; import { FileParamBuilder } from './files-storage-param.builder'; diff --git a/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts b/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts index 7e4ed5a1c83..ef2ac9c9d1d 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts @@ -3,7 +3,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { ErrorMapper, FileRecordParentType, FilesStorageEvents, FilesStorageExchange } from '@shared/infra/rabbitmq'; +import { ErrorMapper, FileRecordParentType, FilesStorageEvents, FilesStorageExchange } from '@infra/rabbitmq'; import { setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FilesStorageProducer } from './files-storage.producer'; diff --git a/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts b/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts index ea049442df4..34927c01831 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts @@ -11,7 +11,7 @@ import { IFileDO, IFileRecordParams, RpcMessageProducer, -} from '@src/shared/infra/rabbitmq'; +} from '@infra/rabbitmq'; import { IFilesStorageClientConfig } from '../interfaces'; @Injectable() diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts index 22a7a11fb5b..4a966165633 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts @@ -6,8 +6,8 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { cleanupCollections, courseFactory, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts index 0557843b8eb..6c1087ce371 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts @@ -6,8 +6,8 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { cleanupCollections, fileRecordFactory, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts index e86b778e0ce..27661af51f8 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts @@ -6,8 +6,8 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import NodeClam from 'clamscan'; import { Request } from 'express'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index e87aa5ddbe6..6ccc6d0d5ea 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -1,4 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { PreviewProducer } from '@infra/preview-generator'; +import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ICurrentUser } from '@modules/authentication'; import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; @@ -6,9 +9,6 @@ import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { PreviewProducer } from '@shared/infra/preview-generator'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import NodeClam from 'clamscan'; import { Request } from 'express'; @@ -63,6 +63,19 @@ class API { }; } + async getPreviewWithEtag(routeName: string, etag: string, query?: string | Record) { + const response = await request(this.app.getHttpServer()) + .get(routeName) + .query(query || {}) + .set('If-None-Match', etag); + + return { + result: response.body as StreamableFile, + error: response.body as ApiValidationError, + status: response.status, + }; + } + async getPreviewBytesRange(routeName: string, bytesRange: string, query?: string | Record) { const response = await request(this.app.getHttpServer()) .get(routeName) @@ -299,34 +312,75 @@ describe('File Controller (API) - preview', () => { return { uploadedFile }; }; - it('should return status 200 for successful download', async () => { - const { uploadedFile } = await setup(); - const query = { - ...defaultQueryParameters, - forceUpdate: false, - }; - - const response = await api.getPreview(`/file/preview/${uploadedFile.id}/${uploadedFile.name}`, query); - - expect(response.status).toEqual(200); + describe('WHEN header contains no etag', () => { + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + + const response = await api.getPreview(`/file/preview/${uploadedFile.id}/${uploadedFile.name}`, query); + + expect(response.status).toEqual(200); + }); + + it('should return status 206 and required headers for the successful partial file stream download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + + const response = await api.getPreviewBytesRange( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + 'bytes=0-', + query + ); + + expect(response.status).toEqual(206); + expect(response.headers['accept-ranges']).toMatch('bytes'); + expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + expect(response.headers.etag).toMatch('testTag'); + }); }); - it('should return status 206 and required headers for the successful partial file stream download', async () => { - const { uploadedFile } = await setup(); - const query = { - ...defaultQueryParameters, - forceUpdate: false, - }; - - const response = await api.getPreviewBytesRange( - `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, - 'bytes=0-', - query - ); + describe('WHEN header contains not matching etag', () => { + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + const etag = 'otherTag'; + + const response = await api.getPreviewWithEtag( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + etag, + query + ); + + expect(response.status).toEqual(200); + }); + }); - expect(response.status).toEqual(206); - expect(response.headers['accept-ranges']).toMatch('bytes'); - expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + describe('WHEN header contains matching etag', () => { + it('should return status 304', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + const etag = 'testTag'; + + const response = await api.getPreviewWithEtag( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + etag, + query + ); + + expect(response.status).toEqual(304); + }); }); }); @@ -369,6 +423,7 @@ describe('File Controller (API) - preview', () => { expect(response.status).toEqual(206); expect(response.headers['accept-ranges']).toMatch('bytes'); expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + expect(response.headers.etag).toMatch('testTag'); }); }); }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts index 496f399d41b..9e666437748 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts @@ -6,8 +6,8 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { cleanupCollections, fileRecordFactory, diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts index 6555b7bd0f9..913a259e9c3 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { StringToBoolean } from '@shared/controller'; import { EntityId } from '@shared/domain'; -import { ScanResult } from '@shared/infra/antivirus'; +import { ScanResult } from '@infra/antivirus'; import { Allow, IsBoolean, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { FileRecordParentType } from '../../entity'; import { PreviewOutputMimeTypes, PreviewWidth } from '../../interface'; diff --git a/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts b/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts index aabefa60f16..fc500fedee1 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts @@ -2,9 +2,9 @@ import { RabbitPayload, RabbitRPC } from '@golevelup/nestjs-rabbitmq'; import { MikroORM, UseRequestContext } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { RpcMessage } from '@shared/infra/rabbitmq/rpc-message'; +import { RpcMessage } from '@infra/rabbitmq/rpc-message'; import { LegacyLogger } from '@src/core/logger'; -import { FilesStorageEvents, FilesStorageExchange, ICopyFileDO, IFileDO } from '@src/shared/infra/rabbitmq'; +import { FilesStorageEvents, FilesStorageExchange, ICopyFileDO, IFileDO } from '@infra/rabbitmq'; import { FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; import { PreviewService } from '../service/preview.service'; diff --git a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts index 736d69e3e29..7269336c44c 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts @@ -120,6 +120,7 @@ export class FilesStorageController { @ApiOperation({ summary: 'Streamable download of a preview file.' }) @ApiResponse({ status: 200, type: StreamableFile }) @ApiResponse({ status: 206, type: StreamableFile }) + @ApiResponse({ status: 304, description: 'Not Modified' }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) @@ -134,8 +135,9 @@ export class FilesStorageController { @Query() previewParams: PreviewParams, @Req() req: Request, @Res({ passthrough: true }) response: Response, - @Headers('Range') bytesRange?: string - ): Promise { + @Headers('Range') bytesRange?: string, + @Headers('If-None-Match') etag?: string + ): Promise { const fileResponse = await this.filesStorageUC.downloadPreview( currentUser.userId, params, @@ -143,6 +145,14 @@ export class FilesStorageController { bytesRange ); + response.set({ ETag: fileResponse.etag }); + + if (etag === fileResponse.etag) { + response.status(HttpStatus.NOT_MODIFIED); + + return undefined; + } + const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange); return streamableFile; diff --git a/apps/server/src/modules/files-storage/dto/file.dto.ts b/apps/server/src/modules/files-storage/dto/file.dto.ts index 9668ac3af72..ecdc3a73296 100644 --- a/apps/server/src/modules/files-storage/dto/file.dto.ts +++ b/apps/server/src/modules/files-storage/dto/file.dto.ts @@ -1,4 +1,4 @@ -import { File } from '@shared/infra/s3-client'; +import { File } from '@infra/s3-client'; import { Readable } from 'stream'; export class FileDto implements File { diff --git a/apps/server/src/modules/files-storage/files-preview-amqp.module.ts b/apps/server/src/modules/files-storage/files-preview-amqp.module.ts index 411a26e76d6..78a1aec0129 100644 --- a/apps/server/src/modules/files-storage/files-preview-amqp.module.ts +++ b/apps/server/src/modules/files-storage/files-preview-amqp.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { PreviewGeneratorConsumerModule } from '@shared/infra/preview-generator'; +import { PreviewGeneratorConsumerModule } from '@infra/preview-generator'; import { defaultConfig, s3Config } from './files-storage.config'; @Module({ diff --git a/apps/server/src/modules/files-storage/files-storage-test.module.ts b/apps/server/src/modules/files-storage/files-storage-test.module.ts index 6f3d865ebb2..f219c8bccac 100644 --- a/apps/server/src/modules/files-storage/files-storage-test.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-test.module.ts @@ -1,11 +1,10 @@ import { DynamicModule, Module } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { MongoDatabaseModuleOptions } from '@shared/infra/database/mongo-memory-database/types'; -import { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq/rabbitmq.module'; +import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { AuthenticationModule } from '@modules/authentication'; import { AuthorizationModule } from '@modules/authorization'; import { FileRecord } from './entity'; import { FilesStorageApiModule } from './files-storage-api.module'; diff --git a/apps/server/src/modules/files-storage/files-storage.config.ts b/apps/server/src/modules/files-storage/files-storage.config.ts index 7fac8ded763..985b07f0ef1 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -1,5 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import { S3Config } from '@shared/infra/s3-client'; +import { S3Config } from '@infra/s3-client'; import { ICoreModuleConfig } from '@src/core'; export const FILES_STORAGE_S3_CONNECTION = 'FILES_STORAGE_S3_CONNECTION'; diff --git a/apps/server/src/modules/files-storage/files-storage.module.ts b/apps/server/src/modules/files-storage/files-storage.module.ts index ccdaeb7f9fa..a7432172a21 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -4,17 +4,16 @@ import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain'; -import { AntivirusModule } from '@shared/infra/antivirus/antivirus.module'; -import { PreviewGeneratorProducerModule } from '@shared/infra/preview-generator'; -import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq/rabbitmq.module'; -import { S3ClientModule } from '@shared/infra/s3-client'; +import { AntivirusModule } from '@infra/antivirus'; +import { PreviewGeneratorProducerModule } from '@infra/preview-generator'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { S3ClientModule } from '@infra/s3-client'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { FileRecord, FileRecordSecurityCheck } from './entity'; import { config, s3Config } from './files-storage.config'; import { FileRecordRepo } from './repo'; -import { FilesStorageService } from './service/files-storage.service'; -import { PreviewService } from './service/preview.service'; +import { FilesStorageService, PreviewService } from './service'; const imports = [ LoggerModule, diff --git a/apps/server/src/modules/files-storage/helper/path.ts b/apps/server/src/modules/files-storage/helper/path.ts index 3ae81aef62d..f11a3ce7b2a 100644 --- a/apps/server/src/modules/files-storage/helper/path.ts +++ b/apps/server/src/modules/files-storage/helper/path.ts @@ -1,5 +1,5 @@ import { EntityId } from '@shared/domain'; -import { CopyFiles } from '@shared/infra/s3-client'; +import { CopyFiles } from '@infra/s3-client'; import { FileRecord } from '../entity'; import { ErrorType } from '../error'; diff --git a/apps/server/src/modules/files-storage/helper/test-helper.ts b/apps/server/src/modules/files-storage/helper/test-helper.ts index a66bec17de2..77671a2d552 100644 --- a/apps/server/src/modules/files-storage/helper/test-helper.ts +++ b/apps/server/src/modules/files-storage/helper/test-helper.ts @@ -1,4 +1,4 @@ -import { GetFile } from '@shared/infra/s3-client'; +import { GetFile } from '@infra/s3-client'; import { Readable } from 'stream'; import { GetFileResponse } from '../interface'; diff --git a/apps/server/src/modules/files-storage/mapper/file-response.builder.ts b/apps/server/src/modules/files-storage/mapper/file-response.builder.ts index 02344e4b3cb..7ad856deb97 100644 --- a/apps/server/src/modules/files-storage/mapper/file-response.builder.ts +++ b/apps/server/src/modules/files-storage/mapper/file-response.builder.ts @@ -1,4 +1,4 @@ -import { GetFile } from '@shared/infra/s3-client'; +import { GetFile } from '@infra/s3-client'; import { GetFileResponse } from '../interface'; export class FileResponseBuilder { diff --git a/apps/server/src/modules/files-storage/mapper/preview.builder.ts b/apps/server/src/modules/files-storage/mapper/preview.builder.ts index 83a16448a98..aea85be53d8 100644 --- a/apps/server/src/modules/files-storage/mapper/preview.builder.ts +++ b/apps/server/src/modules/files-storage/mapper/preview.builder.ts @@ -1,4 +1,4 @@ -import { PreviewFileOptions } from '@shared/infra/preview-generator'; +import { PreviewFileOptions } from '@infra/preview-generator'; import { PreviewParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { createPath, createPreviewFilePath, createPreviewNameHash, getFormat } from '../helper'; diff --git a/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts b/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts index 735359db012..d1dbe490c25 100644 --- a/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts +++ b/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections, fileRecordFactory } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileRecordRepo } from './filerecord.repo'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts index 4ba05e540a8..51e2535e557 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts index 3705f93b51c..353b77837d9 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts index bcee168c2b9..4c5f08e39ef 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { NotAcceptableException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts index 95f7c2d204c..546ac842799 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams, SingleFileParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts index c82f96074f1..3b6dec255fa 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts index 8523c7388fd..eefd8176169 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import _ from 'lodash'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts index 022e6a4bf0d..765de1077bd 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { readableStreamWithFileTypeFactory } from '@shared/testing/factory/readable-stream-with-file-type.factory'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage.service.ts b/apps/server/src/modules/files-storage/service/files-storage.service.ts index 209f1804d3e..6eb9e89ea96 100644 --- a/apps/server/src/modules/files-storage/service/files-storage.service.ts +++ b/apps/server/src/modules/files-storage/service/files-storage.service.ts @@ -8,8 +8,8 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import FileType from 'file-type-cjs/file-type-cjs-index'; import { PassThrough, Readable } from 'stream'; diff --git a/apps/server/src/modules/files-storage/service/index.ts b/apps/server/src/modules/files-storage/service/index.ts new file mode 100644 index 00000000000..f5f1eb61392 --- /dev/null +++ b/apps/server/src/modules/files-storage/service/index.ts @@ -0,0 +1,2 @@ +export * from './files-storage.service'; +export * from './preview.service'; diff --git a/apps/server/src/modules/files-storage/service/preview.service.spec.ts b/apps/server/src/modules/files-storage/service/preview.service.spec.ts index f02f48aee21..a5fed69ef51 100644 --- a/apps/server/src/modules/files-storage/service/preview.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/preview.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { PreviewProducer } from '@shared/infra/preview-generator'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { PreviewProducer } from '@infra/preview-generator'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/service/preview.service.ts b/apps/server/src/modules/files-storage/service/preview.service.ts index e27fbc0645a..0a9ba63e8e1 100644 --- a/apps/server/src/modules/files-storage/service/preview.service.ts +++ b/apps/server/src/modules/files-storage/service/preview.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { PreviewProducer } from '@shared/infra/preview-generator'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { PreviewProducer } from '@infra/preview-generator'; +import { S3ClientAdapter } from '@infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import { PreviewParams } from '../controller/dto'; import { FileRecord, PreviewStatus } from '../entity'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts index 2b7f1052121..cefaba3ac24 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts @@ -4,8 +4,8 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { Action } from '@modules/authorization'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts index b12006367aa..ef0dc16c1b6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts @@ -5,8 +5,8 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { FileRecordParams } from '../controller/dto'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index f0aa9dcc25a..d34f004d73b 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts index 51ad0fd0b77..bcb5b2ec827 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts index 60d3fdd1a64..a4f3e0f8b2f 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts index b66c9c8821d..dc811c566a4 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts index c59f37d2599..5b126c8ea2a 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts index 43d9e9b7750..1125e7644bb 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts @@ -4,8 +4,8 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { AxiosHeadersKeyValue, axiosResponseFactory, fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { Action } from '@modules/authorization'; diff --git a/apps/server/src/modules/files/repo/files.repo.spec.ts b/apps/server/src/modules/files/repo/files.repo.spec.ts index ea33ae7917a..0ef8136918d 100644 --- a/apps/server/src/modules/files/repo/files.repo.spec.ts +++ b/apps/server/src/modules/files/repo/files.repo.spec.ts @@ -1,6 +1,6 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { StorageProviderEntity } from '@shared/domain'; import { FileEntity } from '../entity'; import { fileEntityFactory, filePermissionEntityFactory } from '../entity/testing'; diff --git a/apps/server/src/modules/fwu-learning-contents/controller/api-test/fwu-learning-contents.api.spec.ts b/apps/server/src/modules/fwu-learning-contents/controller/api-test/fwu-learning-contents.api.spec.ts index 9eee09a30af..1c9921dbff5 100644 --- a/apps/server/src/modules/fwu-learning-contents/controller/api-test/fwu-learning-contents.api.spec.ts +++ b/apps/server/src/modules/fwu-learning-contents/controller/api-test/fwu-learning-contents.api.spec.ts @@ -2,7 +2,7 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { INestApplication, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { S3ClientAdapter } from '@infra/s3-client'; import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { Readable } from 'stream'; import request from 'supertest'; diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts index 62e25bef4e2..d644ca2a57c 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts @@ -2,10 +2,10 @@ import { HttpModule } from '@nestjs/axios'; import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Account, Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { MongoDatabaseModuleOptions } from '@shared/infra/database/mongo-memory-database/types'; -import { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq'; -import { S3ClientModule } from '@shared/infra/s3-client'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { MongoDatabaseModuleOptions } from '@infra/database/mongo-memory-database/types'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { S3ClientModule } from '@infra/s3-client'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.config.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.config.ts index 56ae93e0205..6cfcb03b74f 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.config.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.config.ts @@ -1,5 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import { S3Config } from '@shared/infra/s3-client'; +import { S3Config } from '@infra/s3-client'; export const FWU_CONTENT_S3_CONNECTION = 'FWU_CONTENT_S3_CONNECTION'; diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts index b15c8a04054..a991ca56503 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts @@ -4,8 +4,8 @@ import { HttpModule } from '@nestjs/axios'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Account, Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain'; -import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; -import { S3ClientModule } from '@shared/infra/s3-client'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { S3ClientModule } from '@infra/s3-client'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; diff --git a/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.spec.ts b/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.spec.ts index 80240e0ea8e..e7606aeb245 100644 --- a/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.spec.ts +++ b/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { S3ClientAdapter } from '@infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import { Readable } from 'stream'; import { FWU_CONTENT_S3_CONNECTION } from '../fwu-learning-contents.config'; diff --git a/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.ts b/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.ts index afab92d46a5..6cdc20b9321 100644 --- a/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.ts +++ b/apps/server/src/modules/fwu-learning-contents/uc/fwu-learning-contents.uc.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { S3ClientAdapter } from '@infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import { FWU_CONTENT_S3_CONNECTION } from '../fwu-learning-contents.config'; diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index 358b3c13983..1bcc024514a 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,7 +1,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { ExternalSource, SchoolEntity, UserDO, User } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, groupEntityFactory, 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..0c7251e6134 --- /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 '@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..e2af08f3fd5 --- /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 '@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..05132888f71 --- /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 '@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..3f738fd67c0 --- /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 '@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..6e98bb6905a --- /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 '@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..0e1d5a13686 --- /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 '@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..277c233dfa4 --- /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 '@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..49d53c57726 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 { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq'; +import { ALL_ENTITIES } from '@shared/domain'; +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { S3ClientModule } from '@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 { AuthenticationApiModule } from '../authentication/authentication-api.module'; +import { AuthenticationModule } from '@modules/authentication'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { UserModule } from '@modules/user'; +import { AuthenticationApiModule } from '@modules/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..9509cf66a76 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 '@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..c80ff8bd6c0 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 '@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 { AuthenticationModule } from '../authentication/authentication.module'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { UserModule } from '@modules/user'; +import { S3ClientModule } from '@infra/s3-client'; +import { AuthenticationModule } from '@modules/authentication'; 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..f9672ffb3ce --- /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 '@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..79bcde09fe9 --- /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 '@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..e5e763b6216 --- /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 '@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..df19f05ae21 --- /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 '@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..753f40201e3 --- /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 '@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..1b7910f057d --- /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 '@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..aff2b76ae16 --- /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 '@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..b7d65e25cb4 --- /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 '@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..7921b52a27b --- /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 '@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/modules/learnroom/service/column-board-target.service.spec.ts b/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts index 64db859ddac..8a586c39eb6 100644 --- a/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { ColumnBoardTarget } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, columnBoardTargetFactory } from '@shared/testing'; import { ColumnBoardService } from '@modules/board'; import { ColumnBoardTargetService } from './column-board-target.service'; diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index 608249cbf43..ca9d75634cf 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -4,3 +4,4 @@ export * from './column-board-target.service'; export * from './common-cartridge-export.service'; export * from './course.service'; export * from './rooms.service'; +export * from './coursegroup.service'; diff --git a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts index 5439fdef9f6..1688e9e6d97 100644 --- a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolYearEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; import { schoolYearFactory } from '@shared/testing/factory/schoolyear.factory'; import { SchoolYearRepo } from './schoolyear.repo'; diff --git a/apps/server/src/modules/lesson/lesson.module.ts b/apps/server/src/modules/lesson/lesson.module.ts index 2e246c63211..dde1eb157ec 100644 --- a/apps/server/src/modules/lesson/lesson.module.ts +++ b/apps/server/src/modules/lesson/lesson.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { FeathersServiceProvider } from '@shared/infra/feathers'; +import { FeathersServiceProvider } from '@infra/feathers'; import { LessonRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { CopyHelperModule } from '@modules/copy-helper'; diff --git a/apps/server/src/modules/lesson/service/etherpad.service.spec.ts b/apps/server/src/modules/lesson/service/etherpad.service.spec.ts index 81a903008c6..02bc68e67bf 100644 --- a/apps/server/src/modules/lesson/service/etherpad.service.spec.ts +++ b/apps/server/src/modules/lesson/service/etherpad.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain'; -import { FeathersServiceProvider } from '@shared/infra/feathers/feathers-service.provider'; +import { FeathersServiceProvider } from '@infra/feathers/feathers-service.provider'; import { LegacyLogger } from '@src/core/logger'; import { EtherpadService } from './etherpad.service'; diff --git a/apps/server/src/modules/lesson/service/etherpad.service.ts b/apps/server/src/modules/lesson/service/etherpad.service.ts index d630a93420e..62e0773071b 100644 --- a/apps/server/src/modules/lesson/service/etherpad.service.ts +++ b/apps/server/src/modules/lesson/service/etherpad.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { FeathersServiceProvider } from '@shared/infra/feathers/feathers-service.provider'; +import { FeathersServiceProvider } from '@infra/feathers'; import { LegacyLogger } from '@src/core/logger'; export type PadResponse = { data: { padID: string } }; diff --git a/apps/server/src/modules/lesson/service/nexboard.service.spec.ts b/apps/server/src/modules/lesson/service/nexboard.service.spec.ts index a4f40bd1989..8085a5fdd89 100644 --- a/apps/server/src/modules/lesson/service/nexboard.service.spec.ts +++ b/apps/server/src/modules/lesson/service/nexboard.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain'; -import { FeathersServiceProvider } from '@shared/infra/feathers/feathers-service.provider'; +import { FeathersServiceProvider } from '@infra/feathers'; import { LegacyLogger } from '@src/core/logger'; import { NexboardService } from './nexboard.service'; diff --git a/apps/server/src/modules/lesson/service/nexboard.service.ts b/apps/server/src/modules/lesson/service/nexboard.service.ts index 01ca20647ad..31da21a9f92 100644 --- a/apps/server/src/modules/lesson/service/nexboard.service.ts +++ b/apps/server/src/modules/lesson/service/nexboard.service.ts @@ -1,4 +1,4 @@ -import { FeathersServiceProvider } from '@shared/infra/feathers/feathers-service.provider'; +import { FeathersServiceProvider } from '@infra/feathers/feathers-service.provider'; import { LegacyLogger } from '@src/core/logger'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; diff --git a/apps/server/src/modules/management/console/board-management.console.spec.ts b/apps/server/src/modules/management/console/board-management.console.spec.ts index d6027bece93..4fe62db18a5 100644 --- a/apps/server/src/modules/management/console/board-management.console.spec.ts +++ b/apps/server/src/modules/management/console/board-management.console.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { ObjectId } from 'bson'; import { BoardManagementUc } from '../uc/board-management.uc'; import { BoardManagementConsole } from './board-management.console'; diff --git a/apps/server/src/modules/management/console/board-management.console.ts b/apps/server/src/modules/management/console/board-management.console.ts index f2762eccb17..83e5d4d7961 100644 --- a/apps/server/src/modules/management/console/board-management.console.ts +++ b/apps/server/src/modules/management/console/board-management.console.ts @@ -1,4 +1,4 @@ -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { ObjectId } from 'bson'; import { Command, Console } from 'nestjs-console'; import { BoardManagementUc } from '../uc/board-management.uc'; diff --git a/apps/server/src/modules/management/console/database-management.console.spec.ts b/apps/server/src/modules/management/console/database-management.console.spec.ts index f987bddff41..44517e19396 100644 --- a/apps/server/src/modules/management/console/database-management.console.spec.ts +++ b/apps/server/src/modules/management/console/database-management.console.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { DatabaseManagementUc } from '../uc/database-management.uc'; import { DatabaseManagementConsole } from './database-management.console'; diff --git a/apps/server/src/modules/management/console/database-management.console.ts b/apps/server/src/modules/management/console/database-management.console.ts index 29d98005bfe..780072aa837 100644 --- a/apps/server/src/modules/management/console/database-management.console.ts +++ b/apps/server/src/modules/management/console/database-management.console.ts @@ -1,4 +1,4 @@ -import { ConsoleWriterService } from '@shared/infra/console/console-writer/console-writer.service'; +import { ConsoleWriterService } from '@infra/console/console-writer/console-writer.service'; import { Command, Console } from 'nestjs-console'; import { DatabaseManagementUc } from '../uc/database-management.uc'; diff --git a/apps/server/src/modules/management/management-server.module.ts b/apps/server/src/modules/management/management-server.module.ts index b7481646c11..c24bf90cc09 100644 --- a/apps/server/src/modules/management/management-server.module.ts +++ b/apps/server/src/modules/management/management-server.module.ts @@ -2,8 +2,8 @@ import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { DynamicModule, Module, NotFoundException } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { MongoDatabaseModuleOptions } from '@shared/infra/database/mongo-memory-database/types'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { MongoDatabaseModuleOptions } from '@infra/database/mongo-memory-database/types'; import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { ManagementModule } from './management.module'; diff --git a/apps/server/src/modules/management/management.module.ts b/apps/server/src/modules/management/management.module.ts index fc4c8bc08d3..c1ed6aed227 100644 --- a/apps/server/src/modules/management/management.module.ts +++ b/apps/server/src/modules/management/management.module.ts @@ -1,11 +1,11 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { ConsoleWriterService } from '@shared/infra/console'; -import { DatabaseManagementModule, DatabaseManagementService } from '@shared/infra/database'; -import { EncryptionModule } from '@shared/infra/encryption'; -import { FileSystemModule } from '@shared/infra/file-system'; -import { KeycloakConfigurationModule } from '@shared/infra/identity-management/keycloak-configuration/keycloak-configuration.module'; +import { ConsoleWriterService } from '@infra/console'; +import { DatabaseManagementModule, DatabaseManagementService } from '@infra/database'; +import { EncryptionModule } from '@infra/encryption'; +import { FileSystemModule } from '@infra/file-system'; +import { KeycloakConfigurationModule } from '@infra/identity-management/keycloak-configuration/keycloak-configuration.module'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { serverConfig } from '@modules/server'; diff --git a/apps/server/src/modules/management/uc/board-management.uc.ts b/apps/server/src/modules/management/uc/board-management.uc.ts index d57af94e6e9..8fa595690e8 100644 --- a/apps/server/src/modules/management/uc/board-management.uc.ts +++ b/apps/server/src/modules/management/uc/board-management.uc.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { BoardExternalReferenceType, BoardNode, Course, EntityId, InputFormat } from '@shared/domain'; -import { ConsoleWriterService } from '@shared/infra/console'; +import { ConsoleWriterService } from '@infra/console'; import { cardNodeFactory, columnBoardNodeFactory, diff --git a/apps/server/src/modules/management/uc/database-management.uc.spec.ts b/apps/server/src/modules/management/uc/database-management.uc.spec.ts index d74ecb0475e..0aa2c005a39 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.spec.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.spec.ts @@ -4,13 +4,9 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { StorageProviderEntity, SystemEntity } from '@shared/domain'; -import { DatabaseManagementService } from '@shared/infra/database'; -import { - DefaultEncryptionService, - LdapEncryptionService, - SymetricKeyEncryptionService, -} from '@shared/infra/encryption'; -import { FileSystemAdapter } from '@shared/infra/file-system'; +import { DatabaseManagementService } from '@infra/database'; +import { DefaultEncryptionService, LdapEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; +import { FileSystemAdapter } from '@infra/file-system'; import { setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ObjectId } from 'mongodb'; diff --git a/apps/server/src/modules/management/uc/database-management.uc.ts b/apps/server/src/modules/management/uc/database-management.uc.ts index 5a51d249de9..7b3d034c504 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.ts @@ -3,9 +3,9 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { StorageProviderEntity, SystemEntity } from '@shared/domain'; -import { DatabaseManagementService } from '@shared/infra/database'; -import { DefaultEncryptionService, IEncryptionService, LdapEncryptionService } from '@shared/infra/encryption'; -import { FileSystemAdapter } from '@shared/infra/file-system'; +import { DatabaseManagementService } from '@infra/database'; +import { DefaultEncryptionService, IEncryptionService, LdapEncryptionService } from '@infra/encryption'; +import { FileSystemAdapter } from '@infra/file-system'; import { LegacyLogger } from '@src/core/logger'; import { orderBy } from 'lodash'; import { BsonConverter } from '../converter/bson.converter'; diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts index 9350942e1e5..d7a1e0faeff 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts @@ -1,7 +1,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { ConsoleWriterModule } from '@shared/infra/console'; +import { ConsoleWriterModule } from '@infra/console'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '../authentication/authentication.module'; diff --git a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts b/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts index 83a3e3ac47b..bc993271066 100644 --- a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts +++ b/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts @@ -20,7 +20,7 @@ import { ProviderConsentSessionResponse, ProviderLoginResponse, ProviderRedirectResponse, -} from '@shared/infra/oauth-provider/dto'; +} from '@infra/oauth-provider/dto'; import { OauthProviderConsentFlowUc } from '@modules/oauth-provider/uc/oauth-provider.consent-flow.uc'; import { ICurrentUser } from '@modules/authentication'; import { OauthProviderUc } from '@modules/oauth-provider/uc/oauth-provider.uc'; diff --git a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts b/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts index 054cca37ffa..97b16e8e49f 100644 --- a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts +++ b/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts @@ -1,14 +1,14 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ICurrentUser, Authenticate, CurrentUser } from '@modules/authentication'; -// import should be @shared/infra/oauth-provider +// import should be @infra/oauth-provider import { ProviderConsentResponse, ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, ProviderConsentSessionResponse, -} from '@shared/infra/oauth-provider/dto'; +} from '@infra/oauth-provider/dto'; import { ApiTags } from '@nestjs/swagger'; import { OauthProviderLogoutFlowUc } from '../uc/oauth-provider.logout-flow.uc'; import { OauthProviderLoginFlowUc } from '../uc/oauth-provider.login-flow.uc'; diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts index 14db6718476..d34571dcbb7 100644 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts +++ b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts @@ -1,4 +1,4 @@ -import { AcceptLoginRequestBody } from '@shared/infra/oauth-provider/dto'; +import { AcceptLoginRequestBody } from '@infra/oauth-provider/dto'; import { LoginRequestBody } from '../controller/dto'; import { OauthProviderRequestMapper } from './oauth-provider-request.mapper'; diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts index e0d4c4aaef4..aa8aa988408 100644 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts +++ b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts @@ -1,4 +1,4 @@ -import { AcceptLoginRequestBody } from '@shared/infra/oauth-provider/dto'; +import { AcceptLoginRequestBody } from '@infra/oauth-provider/dto'; import { LoginRequestBody } from '@modules/oauth-provider/controller/dto'; export class OauthProviderRequestMapper { diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts index 13119635f75..f28ab378771 100644 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts +++ b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts @@ -5,7 +5,7 @@ import { ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, -} from '@shared/infra/oauth-provider/dto'; +} from '@infra/oauth-provider/dto'; import { ConsentResponse, ConsentSessionResponse, diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts index 01038c23526..c97b86366b0 100644 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts +++ b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts @@ -5,7 +5,7 @@ import { ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, -} from '@shared/infra/oauth-provider/dto'; +} from '@infra/oauth-provider/dto'; import { ConsentResponse, ConsentSessionResponse, diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts index ccbd1566cda..bf131e22eee 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { OauthProviderServiceModule } from '@shared/infra/oauth-provider'; +import { OauthProviderServiceModule } from '@infra/oauth-provider'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; import { PseudonymModule } from '@modules/pseudonym'; diff --git a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts index 4289644d29e..7483ad140e5 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { OauthProviderServiceModule } from '@shared/infra/oauth-provider'; +import { OauthProviderServiceModule } from '@infra/oauth-provider'; import { TeamsRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { LtiToolModule } from '@modules/lti-tool'; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts index 6ce203ab5b7..d2eb1636e53 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, User } from '@shared/domain'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { setupEntities, userFactory } from '@shared/testing'; import { AuthorizationService } from '@modules/authorization'; import { ICurrentUser } from '@modules/authentication'; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts index 3595f00679b..18fd23ae788 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { OauthProviderService } from '@shared/infra/oauth-provider/index'; +import { OauthProviderService } from '@infra/oauth-provider/index'; import { Permission, User } from '@shared/domain/index'; import { AuthorizationService } from '@modules/authorization'; -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { ICurrentUser } from '@modules/authentication'; @Injectable() diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts index b397b048dd4..e56700477a8 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts @@ -1,12 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { OauthProviderService } from '@shared/infra/oauth-provider/index'; +import { OauthProviderService } from '@infra/oauth-provider'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AcceptQuery, ConsentRequestBody } from '@modules/oauth-provider/controller/dto'; -import { - AcceptConsentRequestBody, - ProviderConsentResponse, - ProviderRedirectResponse, -} from '@shared/infra/oauth-provider/dto'; +import { AcceptConsentRequestBody, ProviderConsentResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; import { OauthProviderConsentFlowUc } from '@modules/oauth-provider/uc/oauth-provider.consent-flow.uc'; import { ICurrentUser } from '@modules/authentication'; import { ForbiddenException } from '@nestjs/common'; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts index eb91d8132fe..126f68f1b80 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts @@ -3,12 +3,12 @@ import { ProviderConsentResponse, ProviderRedirectResponse, RejectRequestBody, -} from '@shared/infra/oauth-provider/dto'; +} from '@infra/oauth-provider/dto'; import { AcceptQuery, ConsentRequestBody } from '@modules/oauth-provider/controller/dto'; import { ICurrentUser } from '@modules/authentication'; import { ForbiddenException, Injectable } from '@nestjs/common'; import { IdTokenService } from '@modules/oauth-provider/service/id-token.service'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; +import { OauthProviderService } from '@infra/oauth-provider'; import { IdToken } from '@modules/oauth-provider/interface/id-token'; @Injectable() diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts index a9225031d04..1c160b021d7 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiToolDO, Permission, Pseudonym, UserDO } from '@shared/domain'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { ProviderLoginResponse, ProviderRedirectResponse } from '@shared/infra/oauth-provider/dto'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { ProviderLoginResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; import { externalToolFactory, ltiToolDOFactory, diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts index dade1cb3f07..cfe208c477c 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts @@ -1,12 +1,8 @@ import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Permission, Pseudonym, User, UserDO } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { - AcceptLoginRequestBody, - ProviderLoginResponse, - ProviderRedirectResponse, -} from '@shared/infra/oauth-provider/dto'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { AcceptLoginRequestBody, ProviderLoginResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; import { AuthorizationService } from '@modules/authorization'; import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from '@modules/oauth-provider/controller/dto'; import { OauthProviderRequestMapper } from '@modules/oauth-provider/mapper/oauth-provider-request.mapper'; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts index 778112840b2..62171565cda 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { OauthProviderLogoutFlowUc } from '@modules/oauth-provider/uc/oauth-provider.logout-flow.uc'; -import { OauthProviderService } from '@shared/infra/oauth-provider/index'; +import { OauthProviderService } from '@infra/oauth-provider/index'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; describe('OauthProviderUc', () => { diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts index 68f1eb95bf9..30f45ba4188 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { ProviderRedirectResponse } from '@shared/infra/oauth-provider/dto'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { ProviderRedirectResponse } from '@infra/oauth-provider/dto'; @Injectable() export class OauthProviderLogoutFlowUc { diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts index f1205db3d28..2faf242f0e5 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts @@ -1,8 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { OauthProviderUc } from '@modules/oauth-provider/uc/oauth-provider.uc'; -import { OauthProviderService } from '@shared/infra/oauth-provider/index'; +import { OauthProviderService } from '@infra/oauth-provider/index'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ProviderConsentSessionResponse } from '@shared/infra/oauth-provider/dto'; +import { ProviderConsentSessionResponse } from '@infra/oauth-provider/dto'; describe('OauthProviderUc', () => { let module: TestingModule; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts index 74bf0543d90..39ad1effd7f 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; +import { OauthProviderService } from '@infra/oauth-provider'; import { EntityId } from '@shared/domain'; -import { ProviderConsentSessionResponse } from '@shared/infra/oauth-provider/dto/'; +import { ProviderConsentSessionResponse } from '@infra/oauth-provider/dto/'; @Injectable() export class OauthProviderUc { diff --git a/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts b/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts deleted file mode 100644 index eaaf07f4500..00000000000 --- a/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Account, EntityId, SchoolEntity, SystemEntity, User } from '@shared/domain'; -import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { KeycloakAdministrationService } from '@shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service'; -import { - accountFactory, - cleanupCollections, - mapUserToCurrentUser, - schoolFactory, - systemFactory, - userFactory, -} from '@shared/testing'; -import { JwtTestFactory } from '@shared/testing/factory/jwt.test.factory'; -import { userLoginMigrationFactory } from '@shared/testing/factory/user-login-migration.factory'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; -import { SanisResponse, SanisRole } from '@modules/provisioning/strategy/sanis/response'; -import { ServerTestModule } from '@modules/server'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { UUID } from 'bson'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { SSOAuthenticationError } from '../../interface/sso-authentication-error.enum'; -import { OauthTokenResponse } from '../../service/dto'; -import { AuthorizationParams, SSOLoginQuery } from '../dto'; - -jest.mock('jwks-rsa', () => () => { - return { - getKeys: jest.fn(), - getSigningKey: jest.fn().mockResolvedValue({ - kid: 'kid', - alg: 'RS256', - getPublicKey: jest.fn().mockReturnValue(JwtTestFactory.getPublicKey()), - rsaPublicKey: JwtTestFactory.getPublicKey(), - }), - getSigningKeys: jest.fn(), - }; -}); - -describe('OAuth SSO Controller (API)', () => { - let app: INestApplication; - let em: EntityManager; - let currentUser: ICurrentUser; - let axiosMock: MockAdapter; - - const sessionCookieName: string = Configuration.get('SESSION__NAME') as string; - beforeAll(async () => { - Configuration.set('PUBLIC_BACKEND_URL', 'http://localhost:3030/api'); - const schulcloudJwt: string = JwtTestFactory.createJwt(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - req.headers.authorization = schulcloudJwt; - return true; - }, - }) - .compile(); - - axiosMock = new MockAdapter(axios); - app = moduleRef.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - const kcAdminService = app.get(KeycloakAdministrationService); - - axiosMock.onGet(kcAdminService.getWellKnownUrl()).reply(200, { - issuer: 'issuer', - token_endpoint: 'token_endpoint', - authorization_endpoint: 'authorization_endpoint', - end_session_endpoint: 'end_session_endpoint', - jwks_uri: 'jwks_uri', - }); - }); - - afterAll(async () => { - await app.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - const setupSessionState = async (systemId: EntityId, migration: boolean) => { - const query: SSOLoginQuery = { - migration, - }; - - const response: Response = await request(app.getHttpServer()) - .get(`/sso/login/${systemId}`) - .query(query) - .expect(302) - .expect('set-cookie', new RegExp(`^${sessionCookieName}`)); - - const cookies: string[] = response.get('Set-Cookie'); - const redirect: string = response.get('Location'); - const matchState: RegExpMatchArray | null = redirect.match(/(?<=state=)([^&]+)/); - const state = matchState ? matchState[0] : ''; - - return { - cookies, - state, - }; - }; - - const setup = async () => { - const externalUserId = 'externalUserId'; - const system: SystemEntity = systemFactory.withOauthConfig().buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); - const user: User = userFactory.buildWithId({ externalId: externalUserId, school }); - const account: Account = accountFactory.buildWithId({ systemId: system.id, userId: user.id }); - - await em.persistAndFlush([system, user, school, account]); - em.clear(); - - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'state'; - - return { - system, - user, - externalUserId, - school, - query, - }; - }; - - describe('[GET] sso/login/:systemId', () => { - describe('when no error occurs', () => { - it('should redirect to the authentication url and set a session cookie', async () => { - const { system } = await setup(); - - await request(app.getHttpServer()) - .get(`/sso/login/${system.id}`) - .expect(302) - .expect('set-cookie', new RegExp(`^${sessionCookieName}`)) - .expect( - 'Location', - /^http:\/\/mock.de\/auth\?client_id=12345&redirect_uri=http%3A%2F%2Flocalhost%3A3030%2Fapi%2Fv3%2Fsso%2Foauth&response_type=code&scope=openid\+uuid&state=\w*/ - ); - }); - }); - - describe('when an error occurs', () => { - it('should redirect to the login page', async () => { - const unknownSystemId: string = new ObjectId().toHexString(); - const clientUrl: string = Configuration.get('HOST') as string; - - await request(app.getHttpServer()) - .get(`/sso/login/${unknownSystemId}`) - .expect(302) - .expect('Location', `${clientUrl}/login?error=sso_login_failed`); - }); - }); - }); - - describe('[GET] sso/oauth', () => { - describe('when the session has no oauthLoginState', () => { - it('should return 401 Unauthorized', async () => { - await setup(); - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'state'; - - await request(app.getHttpServer()).get(`/sso/oauth`).query(query).expect(401); - }); - }); - - describe('when the session and the request have a different state', () => { - it('should return 401 Unauthorized', async () => { - const { system } = await setup(); - const { cookies } = await setupSessionState(system.id, false); - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'wrongState'; - - await request(app.getHttpServer()).get(`/sso/oauth`).set('Cookie', cookies).query(query).expect(401); - }); - }); - - describe('when code and state are valid', () => { - it('should set a jwt and redirect', async () => { - const { system, externalUserId, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const baseUrl: string = Configuration.get('HOST') as string; - query.code = 'code'; - query.state = state; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'testUser', - iss: system.oauthConfig?.issuer, - aud: system.oauthConfig?.clientId, - // For OIDC provisioning strategy - external_sub: externalUserId, - }); - - axiosMock.onPost(system.oauthConfig?.tokenEndpoint).reply(200, { - id_token: idToken, - refresh_token: 'refreshToken', - access_token: 'accessToken', - }); - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/dashboard`) - .expect( - (res: Response) => res.get('Set-Cookie').filter((value: string) => value.startsWith('jwt')).length === 1 - ); - }); - }); - - describe('when an error occurs during the login process', () => { - it('should redirect to the login page', async () => { - const { system, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const clientUrl: string = Configuration.get('HOST') as string; - query.error = SSOAuthenticationError.ACCESS_DENIED; - query.state = state; - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${clientUrl}/login?error=access_denied&provider=${system.oauthConfig?.provider as string}` - ); - }); - }); - - describe('when a faulty query is passed', () => { - it('should redirect to the login page with an error', async () => { - const { system, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const clientUrl: string = Configuration.get('HOST') as string; - query.state = state; - query.code = undefined; - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${clientUrl}/login?error=sso_auth_code_step&provider=${system.oauthConfig?.provider as string}` - ); - }); - }); - }); - - describe('[GET] sso/oauth/migration', () => { - const mockPostOauthTokenEndpoint = ( - idToken: string, - targetSystem: SystemEntity, - targetUserId: string, - schoolExternalId: string, - officialSchoolNumber: string - ) => { - axiosMock - .onPost(targetSystem.oauthConfig?.tokenEndpoint) - .replyOnce(200, { - id_token: idToken, - refresh_token: 'refreshToken', - access_token: 'accessToken', - }) - .onGet(targetSystem.provisioningUrl) - .replyOnce(200, { - pid: targetUserId, - person: { - name: { - familienname: 'familienName', - vorname: 'vorname', - }, - geschlecht: 'weiblich', - lokalisierung: 'not necessary', - vertrauensstufe: 'not necessary', - }, - personenkontexte: [ - { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), - rolle: SanisRole.LEHR, - organisation: { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), - kennung: officialSchoolNumber, - name: 'schulName', - typ: 'not necessary', - }, - personenstatus: 'not necessary', - }, - ], - }); - }; - - describe('when the session has no oauthLoginState', () => { - it('should return 401 Unauthorized', async () => { - const { query } = await setup(); - - await request(app.getHttpServer()).get(`/sso/oauth/migration`).query(query).expect(401); - }); - }); - - describe('when the migration is successful', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const targetSchoolExternalId = 'aef1f4fd-c323-466e-962b-a84354c0e714'; - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - const sourceUserAccount: Account = accountFactory.buildWithId({ - userId: sourceUser.id, - systemId: sourceSystem.id, - username: sourceUser.email, - }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceUser, sourceUserAccount, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - targetSchoolExternalId, - sourceSystem, - sourceUser, - externalUserId, - query, - cookies, - }; - }; - - it('should redirect to the success page', async () => { - const { query, sourceUser, targetSystem, externalUserId, cookies, sourceSystem, targetSchoolExternalId } = - await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'testUser', - iss: targetSystem.oauthConfig?.issuer, - aud: targetSystem.oauthConfig?.clientId, - external_sub: externalUserId, - }); - - mockPostOauthTokenEndpoint(idToken, targetSystem, currentUser.userId, targetSchoolExternalId, 'NI_11111'); - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${baseUrl}/migration/success?sourceSystem=${ - currentUser.systemId ? currentUser.systemId : '' - }&targetSystem=${targetSystem.id}` - ); - }); - }); - - describe('when currentUser has no systemId', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11110', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - await em.persistAndFlush([targetSystem, sourceUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - sourceUser, - query, - cookies, - }; - }; - - it('should throw UnprocessableEntityException', async () => { - const { sourceUser, query, cookies } = await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, undefined); - query.error = SSOAuthenticationError.INVALID_REQUEST; - - await request(app.getHttpServer()).get(`/sso/oauth/migration`).set('Cookie', cookies).query(query).expect(422); - }); - }); - - describe('when invalid request', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceSchool, sourceUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - sourceSystem, - sourceUser, - query, - cookies, - }; - }; - - it('should redirect to the general migration error page', async () => { - const { sourceUser, sourceSystem, query, cookies } = await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - query.error = SSOAuthenticationError.INVALID_REQUEST; - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/migration/error`); - }); - }); - - describe('when schoolnumbers mismatch', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const targetSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [targetSystem], - officialSchoolNumber: '22222', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - const targetUser: User = userFactory.buildWithId({ - externalId: 'differentExternalUserId', - school: targetSchool, - }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceUser, targetUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - sourceSystem, - sourceUser, - targetUser, - targetSchoolExternalId: targetSchool.externalId as string, - query, - cookies, - }; - }; - - it('should redirect to the login page with an schoolnumber mismatch error', async () => { - const { targetSystem, sourceUser, targetUser, sourceSystem, targetSchoolExternalId, query, cookies } = - await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'differentExternalUserId', - iss: targetSystem.oauthConfig?.issuer, - aud: targetSystem.oauthConfig?.clientId, - external_sub: 'differentExternalUserId', - }); - - mockPostOauthTokenEndpoint(idToken, targetSystem, targetUser.id, targetSchoolExternalId, 'NI_22222'); - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/migration/error?sourceSchoolNumber=11111&targetSchoolNumber=22222`); - }); - }); - - afterAll(() => { - axiosMock.restore(); - }); - }); -}); diff --git a/apps/server/src/modules/oauth/controller/dto/authorization.params.ts b/apps/server/src/modules/oauth/controller/dto/authorization.params.ts index 1a20985ce43..af76d0799e4 100644 --- a/apps/server/src/modules/oauth/controller/dto/authorization.params.ts +++ b/apps/server/src/modules/oauth/controller/dto/authorization.params.ts @@ -1,9 +1,6 @@ import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { SSOAuthenticationError } from '../../interface/sso-authentication-error.enum'; -/** - * @deprecated - */ export class AuthorizationParams { @IsOptional() @IsString() diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts index 3d1a470e227..c42eeb22e24 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts @@ -8,7 +8,6 @@ import { HydraOauthUc } from '@modules/oauth/uc/hydra-oauth.uc'; import { Request } from 'express'; import { OauthSSOController } from './oauth-sso.controller'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; -import { OauthUc } from '../uc'; describe('OAuthController', () => { let module: TestingModule; @@ -52,10 +51,6 @@ describe('OAuthController', () => { provide: LegacyLogger, useValue: createMock(), }, - { - provide: OauthUc, - useValue: createMock(), - }, { provide: HydraOauthUc, useValue: createMock(), diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts index 5ff7e7cae02..61ed319d1cd 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -1,150 +1,18 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { - Controller, - Get, - InternalServerErrorException, - Param, - Query, - Req, - Res, - Session, - UnauthorizedException, - UnprocessableEntityException, -} from '@nestjs/common'; -import { ApiOkResponse, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ISession } from '@shared/domain/types/session'; +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, Get, Param, Query, Req, UnauthorizedException } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { LegacyLogger } from '@src/core/logger'; -import { ICurrentUser, Authenticate, CurrentUser, JWT } from '@modules/authentication'; -import { OAuthMigrationError } from '@modules/user-login-migration/error/oauth-migration.error'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { CookieOptions, Request, Response } from 'express'; -import { HydraOauthUc } from '../uc/hydra-oauth.uc'; -import { UserMigrationResponse } from './dto/user-migration.response'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; +import { Request } from 'express'; import { OAuthTokenDto } from '../interface'; -import { OauthLoginStateMapper } from '../mapper/oauth-login-state.mapper'; -import { UserMigrationMapper } from '../mapper/user-migration.mapper'; -import { OAuthProcessDto } from '../service/dto'; -import { OauthUc } from '../uc'; -import { OauthLoginStateDto } from '../uc/dto/oauth-login-state.dto'; -import { AuthorizationParams, SSOLoginQuery, SystemIdParams } from './dto'; +import { HydraOauthUc } from '../uc'; +import { AuthorizationParams } from './dto'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; @ApiTags('SSO') @Controller('sso') export class OauthSSOController { - private readonly clientUrl: string; - - constructor( - private readonly oauthUc: OauthUc, - private readonly hydraUc: HydraOauthUc, - private readonly logger: LegacyLogger - ) { + constructor(private readonly hydraUc: HydraOauthUc, private readonly logger: LegacyLogger) { this.logger.setContext(OauthSSOController.name); - this.clientUrl = Configuration.get('HOST') as string; - } - - private errorHandler(error: unknown, session: ISession, res: Response, provider?: string) { - this.logger.error(error); - const ssoError: OAuthSSOError = error instanceof OAuthSSOError ? error : new OAuthSSOError(); - - session.destroy((err) => { - this.logger.log(err); - }); - - const errorRedirect: URL = new URL('/login', this.clientUrl); - errorRedirect.searchParams.append('error', ssoError.errorcode); - - if (provider) { - errorRedirect.searchParams.append('provider', provider); - } - - res.redirect(errorRedirect.toString()); - } - - private migrationErrorHandler(error: unknown, session: ISession, res: Response) { - const migrationError: OAuthMigrationError = - error instanceof OAuthMigrationError ? error : new OAuthMigrationError(); - - session.destroy((err) => { - this.logger.log(err); - }); - - const errorRedirect: URL = new URL('/migration/error', this.clientUrl); - - if (migrationError.officialSchoolNumberFromSource && migrationError.officialSchoolNumberFromTarget) { - errorRedirect.searchParams.append('sourceSchoolNumber', migrationError.officialSchoolNumberFromSource); - errorRedirect.searchParams.append('targetSchoolNumber', migrationError.officialSchoolNumberFromTarget); - } - - res.redirect(errorRedirect.toString()); - } - - private sessionHandler(session: ISession, query: AuthorizationParams): OauthLoginStateDto { - if (!session.oauthLoginState) { - throw new UnauthorizedException('Oauth session not found'); - } - - const oauthLoginState: OauthLoginStateDto = OauthLoginStateMapper.mapSessionToDto(session); - - if (oauthLoginState.state !== query.state) { - throw new UnauthorizedException(`Invalid state. Got: ${query.state} Expected: ${oauthLoginState.state}`); - } - - return oauthLoginState; - } - - @Get('login/:systemId') - async getAuthenticationUrl( - @Session() session: ISession, - @Res() res: Response, - @Param() params: SystemIdParams, - @Query() query: SSOLoginQuery - ): Promise { - try { - const redirect: string = await this.oauthUc.startOauthLogin( - session, - params.systemId, - query.migration || false, - query.postLoginRedirect - ); - - res.redirect(redirect); - } catch (error) { - this.errorHandler(error, session, res); - } - } - - @Get('oauth') - async startOauthAuthorizationCodeFlow( - @Session() session: ISession, - @Res() res: Response, - @Query() query: AuthorizationParams - ): Promise { - const oauthLoginState: OauthLoginStateDto = this.sessionHandler(session, query); - - try { - const oauthProcessDto: OAuthProcessDto = await this.oauthUc.processOAuthLogin( - oauthLoginState, - query.code, - query.error - ); - - if (oauthProcessDto.jwt) { - const cookieDefaultOptions: CookieOptions = { - httpOnly: Configuration.get('COOKIE__HTTP_ONLY') as boolean, - sameSite: Configuration.get('COOKIE__SAME_SITE') as 'lax' | 'strict' | 'none', - secure: Configuration.get('COOKIE__SECURE') as boolean, - expires: new Date(Date.now() + (Configuration.get('COOKIE__EXPIRES_SECONDS') as number)), - }; - - res.cookie('jwt', oauthProcessDto.jwt, cookieDefaultOptions); - } - - res.redirect(oauthProcessDto.redirect); - } catch (error) { - this.errorHandler(error, session, res, oauthLoginState.provider); - } } @Get('hydra/:oauthClientId') @@ -166,7 +34,7 @@ export class OauthSSOController { ): Promise { let jwt: string; const authHeader: string | undefined = req.headers.authorization; - if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + if (authHeader?.toLowerCase()?.startsWith('bearer ')) { [, jwt] = authHeader.split(' '); } else { throw new UnauthorizedException( @@ -175,30 +43,4 @@ export class OauthSSOController { } return this.hydraUc.requestAuthCode(currentUser.userId, jwt, oauthClientId); } - - @Get('oauth/migration') - @Authenticate('jwt') - @ApiOkResponse({ description: 'The User has been succesfully migrated.' }) - @ApiResponse({ type: InternalServerErrorException, description: 'The migration of the User was not possible. ' }) - async migrateUser( - @JWT() jwt: string, - @Session() session: ISession, - @CurrentUser() currentUser: ICurrentUser, - @Query() query: AuthorizationParams, - @Res() res: Response - ): Promise { - const oauthLoginState: OauthLoginStateDto = this.sessionHandler(session, query); - - if (!currentUser.systemId) { - throw new UnprocessableEntityException('Current user does not have a system.'); - } - - try { - const migration: MigrationDto = await this.oauthUc.migrate(jwt, currentUser.userId, query, oauthLoginState); - const response: UserMigrationResponse = UserMigrationMapper.mapDtoToResponse(migration); - res.redirect(response.redirect); - } catch (error) { - this.migrationErrorHandler(error, session, res); - } - } } diff --git a/apps/server/src/modules/oauth/loggable/index.ts b/apps/server/src/modules/oauth/loggable/index.ts index b4e63107161..4c35983a4ca 100644 --- a/apps/server/src/modules/oauth/loggable/index.ts +++ b/apps/server/src/modules/oauth/loggable/index.ts @@ -1,3 +1,4 @@ export * from './oauth-sso.error'; export * from './sso-error-code.enum'; export * from './user-not-found-after-provisioning.loggable-exception'; +export * from './token-request-loggable-exception'; diff --git a/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts index 35659b2778f..cc1486adcb7 100644 --- a/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts +++ b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts @@ -1,6 +1,10 @@ import { InternalServerErrorException } from '@nestjs/common'; import { SSOErrorCode } from './sso-error-code.enum'; +/** + * @deprecated Please create a loggable instead. + * This will be removed with: https://ticketsystem.dbildungscloud.de/browse/N21-1483 + */ export class OAuthSSOError extends InternalServerErrorException { readonly message: string; diff --git a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts new file mode 100644 index 00000000000..6716175bdbe --- /dev/null +++ b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts @@ -0,0 +1,34 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { TokenRequestLoggableException } from './token-request-loggable-exception'; + +describe(TokenRequestLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + const exception = new TokenRequestLoggableException(axiosError); + + return { + axiosError, + exception, + error, + }; + }; + + it('should return the correct log message', () => { + const { axiosError, exception, error } = setup(); + + const logMessage = exception.getLogMessage(); + + expect(logMessage).toStrictEqual({ + type: 'OAUTH_TOKEN_REQUEST_ERROR', + message: axiosError.message, + data: JSON.stringify(error), + stack: axiosError.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts new file mode 100644 index 00000000000..fd852186829 --- /dev/null +++ b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts @@ -0,0 +1,8 @@ +import { AxiosErrorLoggable } from '@src/core/error/loggable'; +import { AxiosError } from 'axios'; + +export class TokenRequestLoggableException extends AxiosErrorLoggable { + constructor(error: AxiosError) { + super(error, 'OAUTH_TOKEN_REQUEST_ERROR'); + } +} diff --git a/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts b/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts deleted file mode 100644 index 67c1ae8a6ef..00000000000 --- a/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ISession } from '@shared/domain/types/session'; -import { OauthLoginStateDto } from '../uc/dto/oauth-login-state.dto'; - -export class OauthLoginStateMapper { - static mapSessionToDto(session: ISession): OauthLoginStateDto { - const dto = new OauthLoginStateDto(session.oauthLoginState as OauthLoginStateDto); - return dto; - } -} diff --git a/apps/server/src/modules/oauth/oauth-api.module.ts b/apps/server/src/modules/oauth/oauth-api.module.ts index 98e62d87eca..2efacf66adf 100644 --- a/apps/server/src/modules/oauth/oauth-api.module.ts +++ b/apps/server/src/modules/oauth/oauth-api.module.ts @@ -1,15 +1,15 @@ -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; -import { ProvisioningModule } from '@modules/provisioning'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { ProvisioningModule } from '@modules/provisioning'; import { SystemModule } from '@modules/system'; import { UserModule } from '@modules/user'; import { UserLoginMigrationModule } from '@modules/user-login-migration'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { OauthSSOController } from './controller/oauth-sso.controller'; import { OauthModule } from './oauth.module'; -import { HydraOauthUc, OauthUc } from './uc'; +import { HydraOauthUc } from './uc'; @Module({ imports: [ @@ -24,6 +24,6 @@ import { HydraOauthUc, OauthUc } from './uc'; LoggerModule, ], controllers: [OauthSSOController], - providers: [OauthUc, HydraOauthUc], + providers: [HydraOauthUc], }) export class OauthApiModule {} diff --git a/apps/server/src/modules/oauth/oauth.module.ts b/apps/server/src/modules/oauth/oauth.module.ts index 273a099159b..ae0f0eda48d 100644 --- a/apps/server/src/modules/oauth/oauth.module.ts +++ b/apps/server/src/modules/oauth/oauth.module.ts @@ -1,7 +1,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { CacheWrapperModule } from '@shared/infra/cache'; -import { EncryptionModule } from '@shared/infra/encryption'; +import { CacheWrapperModule } from '@infra/cache'; +import { EncryptionModule } from '@infra/encryption'; import { LtiToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; diff --git a/apps/server/src/modules/oauth/service/hydra.service.spec.ts b/apps/server/src/modules/oauth/service/hydra.service.spec.ts index 3886aa40a58..2dc2a22a6ce 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.spec.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.spec.ts @@ -6,7 +6,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiPrivacyPermission, LtiRoleType, OauthConfig } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { LtiToolRepo } from '@shared/repo'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/oauth/service/hydra.service.ts b/apps/server/src/modules/oauth/service/hydra.service.ts index 9b335604825..360926d080a 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.ts @@ -4,7 +4,7 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { OauthConfig } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; import { LtiToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationParams } from '@modules/oauth/controller/dto/authorization.params'; diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts index 12c0a381d8b..af03a6fdda2 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts @@ -2,9 +2,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; import { of, throwError } from 'rxjs'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; -import { OAuthSSOError } from '../loggable'; +import { TokenRequestLoggableException } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -93,12 +95,65 @@ describe('OauthAdapterServive', () => { }); describe('when no token got returned', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { + error, + }; + }; + it('should throw an error', async () => { - httpService.post.mockReturnValueOnce(throwError(() => 'error')); + const { error } = setup(); const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); - await expect(resp).rejects.toEqual(new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step')); + await expect(resp).rejects.toEqual(error); + }); + }); + + describe('when error got returned', () => { + describe('when error is a unknown error', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { + error, + }; + }; + + it('should throw the default sso error', async () => { + const { error } = setup(); + + const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + + await expect(resp).rejects.toEqual(error); + }); + }); + + describe('when error is a axios error', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + + httpService.post.mockReturnValueOnce(throwError(() => axiosError)); + + return { + axiosError, + }; + }; + + it('should throw an error', async () => { + const { axiosError } = setup(); + + const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + + await expect(resp).rejects.toEqual(new TokenRequestLoggableException(axiosError)); + }); }); }); }); diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts index 6b008b610cf..4ab048b84c4 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts @@ -1,10 +1,10 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common/decorators'; -import { AxiosResponse } from 'axios'; +import { AxiosResponse, isAxiosError } from 'axios'; import JwksRsa from 'jwks-rsa'; import QueryString from 'qs'; import { lastValueFrom, Observable } from 'rxjs'; -import { OAuthSSOError } from '../loggable'; +import { TokenRequestLoggableException } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @Injectable() @@ -40,8 +40,11 @@ export class OauthAdapterService { let responseToken: AxiosResponse; try { responseToken = await lastValueFrom(observable); - } catch (error) { - throw new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step'); + } catch (error: unknown) { + if (isAxiosError(error)) { + throw new TokenRequestLoggableException(error); + } + throw error; } return responseToken.data; diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 2743037e214..1ea2fe5ce07 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -1,23 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity } from '@shared/domain'; -import { UserDO } from '@shared/domain/domainobject/user.do'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@shared/infra/encryption'; -import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; +import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; +import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningDto, ProvisioningService } from '@modules/provisioning'; import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; import { OauthConfigDto } from '@modules/system/service'; import { SystemDto } from '@modules/system/service/dto/system.dto'; import { SystemService } from '@modules/system/service/system.service'; import { UserService } from '@modules/user'; import { MigrationCheckService, UserMigrationService } from '@modules/user-login-migration'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity } from '@shared/domain'; +import { UserDO } from '@shared/domain/domainobject/user.do'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; import { OAuthService } from './oauth.service'; @@ -560,80 +560,4 @@ describe('OAuthService', () => { }); }); }); - - describe('getAuthenticationUrl is called', () => { - describe('when a normal authentication url is requested', () => { - it('should return a authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', false); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth&response_type=code&scope=openid+uuid&state=state' - ); - }); - }); - - describe('when a migration authentication url is requested', () => { - it('should return a authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost.de/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', true); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth%2Fmigration&response_type=code&scope=openid+uuid&state=state' - ); - }); - - it('should return add an idp hint if existing authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost.de/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - idpHint: 'TheIdpHint', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', true); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth%2Fmigration&response_type=code&scope=openid+uuid&state=state&kc_idp_hint=TheIdpHint' - ); - }); - }); - }); }); diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 28a24c0534a..1f7935d919e 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -2,7 +2,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { EntityId, LegacySchoolDo, OauthConfig, SchoolFeatures, UserDO } from '@shared/domain'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; import { LegacyLogger } from '@src/core/logger'; import { ProvisioningService } from '@modules/provisioning'; import { OauthDataDto } from '@modules/provisioning/dto'; @@ -186,31 +186,6 @@ export class OAuthService { return redirect; } - getAuthenticationUrl(oauthConfig: OauthConfig, state: string, migration: boolean): string { - const redirectUri: string = this.getRedirectUri(migration); - - const authenticationUrl: URL = new URL(oauthConfig.authEndpoint); - authenticationUrl.searchParams.append('client_id', oauthConfig.clientId); - authenticationUrl.searchParams.append('redirect_uri', redirectUri); - authenticationUrl.searchParams.append('response_type', oauthConfig.responseType); - authenticationUrl.searchParams.append('scope', oauthConfig.scope); - authenticationUrl.searchParams.append('state', state); - if (oauthConfig.idpHint) { - authenticationUrl.searchParams.append('kc_idp_hint', oauthConfig.idpHint); - } - - return authenticationUrl.toString(); - } - - getRedirectUri(migration: boolean) { - const publicBackendUrl: string = Configuration.get('PUBLIC_BACKEND_URL') as string; - - const path: string = migration ? 'api/v3/sso/oauth/migration' : 'api/v3/sso/oauth'; - const redirectUri: URL = new URL(path, publicBackendUrl); - - return redirectUri.toString(); - } - private buildTokenRequestPayload( code: string, oauthConfig: OauthConfig, diff --git a/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts b/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts deleted file mode 100644 index 10d01b596d2..00000000000 --- a/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { EntityId } from '@shared/domain'; - -export class OauthLoginStateDto { - state: string; - - systemId: EntityId; - - provider: string; - - postLoginRedirect?: string; - - userLoginMigration: boolean; - - constructor(props: OauthLoginStateDto) { - this.state = props.state; - this.systemId = props.systemId; - this.postLoginRedirect = props.postLoginRedirect; - this.provider = props.provider; - this.userLoginMigration = props.userLoginMigration; - } -} diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts index 3d42b0e977f..339bc6c09d9 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts @@ -13,7 +13,7 @@ import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; +import { OAuthSSOError } from '../loggable'; import { OAuthTokenDto } from '../interface'; class HydraOauthUcSpec extends HydraOauthUc { diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index 905cd3c8802..2c461e6db4d 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -1,12 +1,11 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { OauthConfig } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError } from '../loggable'; import { HydraSsoService } from '../service/hydra.service'; import { OAuthService } from '../service/oauth.service'; @@ -22,8 +21,6 @@ export class HydraOauthUc { private readonly MAX_REDIRECTS: number = 10; - private readonly HYDRA_PUBLIC_URI: string = Configuration.get('HYDRA_PUBLIC_URI') as string; - async getOauthToken(oauthClientId: string, code?: string, error?: string): Promise { if (error || !code) { throw new OAuthSSOError( diff --git a/apps/server/src/modules/oauth/uc/index.ts b/apps/server/src/modules/oauth/uc/index.ts index 32e4dce0f74..e1a569e5f88 100644 --- a/apps/server/src/modules/oauth/uc/index.ts +++ b/apps/server/src/modules/oauth/uc/index.ts @@ -1,2 +1 @@ -export * from './oauth.uc'; export * from './hydra-oauth.uc'; diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts deleted file mode 100644 index 1e888abd5f1..00000000000 --- a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { OauthUc } from '@modules/oauth/uc/oauth.uc'; -import { ProvisioningService } from '@modules/provisioning'; -import { ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; -import { SystemService } from '@modules/system'; -import { OauthConfigDto, SystemDto } from '@modules/system/service'; -import { UserService } from '@modules/user'; -import { UserMigrationService } from '@modules/user-login-migration'; -import { OAuthMigrationError } from '@modules/user-login-migration/error/oauth-migration.error'; -import { SchoolMigrationService } from '@modules/user-login-migration/service'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, UserDO } from '@shared/domain'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { ISession } from '@shared/domain/types/session'; -import { legacySchoolDoFactory, setupEntities } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { OauthCurrentUser } from '@modules/authentication/interface'; -import { AuthorizationParams } from '../controller/dto'; -import { OAuthTokenDto } from '../interface'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; -import { OAuthProcessDto } from '../service/dto'; -import { OAuthService } from '../service/oauth.service'; -import { OauthLoginStateDto } from './dto/oauth-login-state.dto'; -import resetAllMocks = jest.resetAllMocks; - -jest.mock('nanoid', () => { - return { - nanoid: () => 'mockNanoId', - }; -}); - -describe('OAuthUc', () => { - let module: TestingModule; - let uc: OauthUc; - - let authenticationService: DeepMocked; - let oauthService: DeepMocked; - let systemService: DeepMocked; - let provisioningService: DeepMocked; - let userMigrationService: DeepMocked; - let userService: DeepMocked; - let schoolMigrationService: DeepMocked; - - beforeAll(async () => { - await setupEntities(); - - module = await Test.createTestingModule({ - providers: [ - OauthUc, - { - provide: LegacyLogger, - useValue: createMock(), - }, - { - provide: SystemService, - useValue: createMock(), - }, - { - provide: OAuthService, - useValue: createMock(), - }, - { - provide: AuthenticationService, - useValue: createMock(), - }, - { - provide: ProvisioningService, - useValue: createMock(), - }, - { - provide: UserService, - useValue: createMock(), - }, - { - provide: LegacySchoolService, - useValue: createMock(), - }, - { - provide: UserMigrationService, - useValue: createMock(), - }, - { - provide: SchoolMigrationService, - useValue: createMock(), - }, - { - provide: AuthenticationService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthUc); - systemService = module.get(SystemService); - authenticationService = module.get(AuthenticationService); - oauthService = module.get(OAuthService); - provisioningService = module.get(ProvisioningService); - userService = module.get(UserService); - userMigrationService = module.get(UserMigrationService); - schoolMigrationService = module.get(SchoolMigrationService); - authenticationService = module.get(AuthenticationService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - resetAllMocks(); - }); - - const createOAuthTestData = () => { - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - return { - system, - systemId: system.id as string, - oauthConfig, - }; - }; - - describe('startOauthLogin', () => { - describe('when starting an oauth login without migration', () => { - const setup = () => { - const { system, systemId } = createOAuthTestData(); - - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - systemId, - session, - authenticationUrl, - }; - }; - - it('should return the authentication url for the system', async () => { - const { systemId, session, authenticationUrl } = setup(); - - const result: string = await uc.startOauthLogin(session, systemId, false); - - expect(result).toEqual(authenticationUrl); - }); - }); - - describe('when starting an oauth login during a migration', () => { - const setup = () => { - const { system, systemId } = createOAuthTestData(); - - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - const postLoginRedirect = 'postLoginRedirect'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - system, - systemId, - postLoginRedirect, - session, - }; - }; - - it('should save data to the session', async () => { - const { systemId, system, session, postLoginRedirect } = setup(); - - await uc.startOauthLogin(session, systemId, false, postLoginRedirect); - - expect(session.oauthLoginState).toEqual({ - systemId, - state: 'mockNanoId', - postLoginRedirect, - provider: system.oauthConfig?.provider as string, - userLoginMigration: false, - }); - }); - }); - - describe('when the system cannot be found', () => { - const setup = () => { - const { systemId, system } = createOAuthTestData(); - system.oauthConfig = undefined; - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - systemId, - session, - authenticationUrl, - }; - }; - - it('should throw UnprocessableEntityException', async () => { - const { systemId, session } = setup(); - - const func = async () => uc.startOauthLogin(session, systemId, false); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); - }); - - describe('processOAuth', () => { - const setup = () => { - const postLoginRedirect = 'postLoginRedirect'; - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - postLoginRedirect, - provider: 'mock_provider', - userLoginMigration: false, - }); - const code = 'code'; - const error = 'error'; - - const jwt = 'schulcloudJwt'; - const redirect = 'redirect'; - const user: UserDO = new UserDO({ - id: 'mockUserId', - firstName: 'firstName', - lastName: 'lastame', - email: '', - roles: [], - schoolId: 'mockSchoolId', - externalId: 'mockExternalId', - }); - - const currentUser: OauthCurrentUser = { userId: 'userId', isExternalUser: true } as OauthCurrentUser; - const testSystem: SystemDto = new SystemDto({ - id: 'mockSystemId', - type: 'mock', - oauthConfig: { provider: 'testProvider' } as OauthConfigDto, - }); - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - return { cachedState, code, error, jwt, redirect, user, currentUser, testSystem, tokenDto }; - }; - - describe('when a user is returned', () => { - it('should return a response with a valid jwt', async () => { - const { cachedState, code, error, jwt, redirect, user, currentUser, tokenDto } = setup(); - - userService.getResolvedUser.mockResolvedValue(currentUser); - authenticationService.generateJwt.mockResolvedValue({ accessToken: jwt }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ user, redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code, error); - expect(response).toEqual( - expect.objectContaining({ - jwt, - redirect, - }) - ); - }); - }); - - describe('when no user is returned', () => { - it('should return a response without a jwt', async () => { - const { cachedState, code, error, redirect, tokenDto } = setup(); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code, error); - - expect(response).toEqual({ - redirect, - }); - }); - }); - - describe('when an error occurs', () => { - it('should return an OAuthProcessDto with error', async () => { - const { cachedState, code, error, testSystem } = setup(); - oauthService.authenticateUser.mockRejectedValue(new OAuthSSOError('Testmessage')); - systemService.findById.mockResolvedValue(testSystem); - - const response = uc.processOAuthLogin(cachedState, code, error); - - await expect(response).rejects.toThrow(OAuthSSOError); - }); - }); - - describe('when the process runs successfully', () => { - it('should return a valid jwt', async () => { - const { cachedState, code, user, currentUser, jwt, redirect, tokenDto } = setup(); - - userService.getResolvedUser.mockResolvedValue(currentUser); - authenticationService.generateJwt.mockResolvedValue({ accessToken: jwt }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ user, redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code); - - expect(response).toEqual({ - jwt, - redirect, - }); - }); - }); - }); - - describe('migration', () => { - describe('migrate', () => { - describe('when authorize user and migration was successful', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - systemService.findById.mockResolvedValue(system); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - }; - }; - - it('should return redirect to migration succeed page', async () => { - const { query, cachedState } = setupMigration(); - - const result: MigrationDto = await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(result.redirect).toStrictEqual('https://mock.de/migration/succeed'); - }); - - it('should remove the jwt from the whitelist', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(authenticationService.removeJwtFromWhitelist).toHaveBeenCalledWith('jwt'); - }); - }); - - describe('when the jwt cannot be removed', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - const error: Error = new Error('testError'); - systemService.findById.mockResolvedValue(system); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - authenticationService.removeJwtFromWhitelist.mockRejectedValue(error); - - return { - query, - cachedState, - error, - }; - }; - - it('should throw', async () => { - const { query, error, cachedState } = setupMigration(); - - const func = () => uc.migrate('jwt', 'currentUserId', query, cachedState); - - await expect(func).rejects.toThrow(error); - }); - }); - - describe('when migration failed', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationFailedDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/dashboard', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - userMigrationService.migrateUser.mockResolvedValue(userMigrationFailedDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - }; - }; - - it('should return redirect to dashboard ', async () => { - const { query, cachedState } = setupMigration(); - - const result: MigrationDto = await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(result.redirect).toStrictEqual('https://mock.de/dashboard'); - }); - }); - - describe('when external school and official school number is defined ', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - oauthData, - }; - }; - - it('should call schoolToMigrate', async () => { - const { oauthData, query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.schoolToMigrate).toHaveBeenCalledWith( - 'currentUserId', - oauthData.externalSchool?.externalId, - oauthData.externalSchool?.officialSchoolNumber - ); - }); - }); - - describe('when external school and official school number is defined and school has to be migrated', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - const schoolToMigrate: LegacySchoolDo | void = legacySchoolDoFactory.build({ name: 'mockName' }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockResolvedValue(schoolToMigrate); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - oauthData, - schoolToMigrate, - }; - }; - - it('should call migrateSchool', async () => { - const { oauthData, query, cachedState, schoolToMigrate } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( - oauthData.externalSchool?.externalId, - schoolToMigrate, - 'systemId' - ); - }); - }); - - describe('when external school and official school number is defined and school is already migrated', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockResolvedValue(null); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - }; - }; - - it('should not call migrateSchool', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); - }); - }); - - describe('when external school is not defined', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.authenticateUser.mockResolvedValue(tokenDto); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - return { - query, - cachedState, - }; - }; - - it('should not call schoolToMigrate', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.schoolToMigrate).not.toHaveBeenCalled(); - }); - }); - - describe('when official school number is not defined', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - const error = new OAuthMigrationError( - 'Official school number from target migration system is missing', - 'ext_official_school_number_missing' - ); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockImplementation(() => { - throw error; - }); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - error, - }; - }; - - it('should throw OAuthMigrationError', async () => { - const { query, cachedState, error } = setupMigration(); - - await expect(uc.migrate('jwt', 'currentUserId', query, cachedState)).rejects.toThrow(error); - }); - }); - }); - - describe('when state is mismatched', () => { - const setupMigration = () => { - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const query: AuthorizationParams = { state: 'failedState' }; - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.authenticateUser.mockResolvedValue(tokenDto); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - return { - cachedState, - query, - }; - }; - - it('should throw an UnauthorizedException', async () => { - const { cachedState, query } = setupMigration(); - - const response = uc.migrate('jwt', 'currentUserId', query, cachedState); - - await expect(response).rejects.toThrow(UnauthorizedException); - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.ts b/apps/server/src/modules/oauth/uc/oauth.uc.ts deleted file mode 100644 index c495e7be05d..00000000000 --- a/apps/server/src/modules/oauth/uc/oauth.uc.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Injectable, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, LegacySchoolDo, UserDO } from '@shared/domain'; -import { ISession } from '@shared/domain/types/session'; -import { LegacyLogger } from '@src/core/logger'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { ProvisioningService } from '@modules/provisioning'; -import { OauthDataDto } from '@modules/provisioning/dto'; -import { SystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { UserService } from '@modules/user'; -import { UserMigrationService } from '@modules/user-login-migration'; -import { SchoolMigrationService } from '@modules/user-login-migration/service'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { nanoid } from 'nanoid'; -import { OauthCurrentUser } from '@modules/authentication/interface'; -import { AuthorizationParams } from '../controller/dto'; -import { OAuthTokenDto } from '../interface'; -import { OAuthProcessDto } from '../service/dto'; -import { OAuthService } from '../service/oauth.service'; -import { OauthLoginStateDto } from './dto/oauth-login-state.dto'; - -/** - * @deprecated remove after login via oauth moved to authentication module - */ -@Injectable() -export class OauthUc { - constructor( - private readonly oauthService: OAuthService, - private readonly authenticationService: AuthenticationService, - private readonly systemService: SystemService, - private readonly provisioningService: ProvisioningService, - private readonly userService: UserService, - private readonly userMigrationService: UserMigrationService, - private readonly schoolMigrationService: SchoolMigrationService, - private readonly logger: LegacyLogger - ) { - this.logger.setContext(OauthUc.name); - } - - async startOauthLogin( - session: ISession, - systemId: EntityId, - migration: boolean, - postLoginRedirect?: string - ): Promise { - const state = nanoid(16); - - const system: SystemDto = await this.systemService.findById(systemId); - if (!system.oauthConfig) { - throw new UnprocessableEntityException(`Requested system ${systemId} has no oauth configured`); - } - - const authenticationUrl: string = this.oauthService.getAuthenticationUrl(system.oauthConfig, state, migration); - - session.oauthLoginState = new OauthLoginStateDto({ - state, - systemId, - provider: system.oauthConfig.provider, - postLoginRedirect, - userLoginMigration: migration, - }); - - return authenticationUrl; - } - - async processOAuthLogin(cachedState: OauthLoginStateDto, code?: string, error?: string): Promise { - const { state, systemId, postLoginRedirect, userLoginMigration } = cachedState; - - this.logger.debug(`Oauth login process started. [state: ${state}, system: ${systemId}]`); - - const redirectUri: string = this.oauthService.getRedirectUri(userLoginMigration); - - const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(systemId, redirectUri, code, error); - - const { user, redirect }: { user?: UserDO; redirect: string } = await this.oauthService.provisionUser( - systemId, - tokenDto.idToken, - tokenDto.accessToken, - postLoginRedirect - ); - - this.logger.debug(`Generating jwt for user. [state: ${state}, system: ${systemId}]`); - - let jwt: string | undefined; - if (user && user.id) { - jwt = await this.getJwtForUser(user.id); - } - - const response = new OAuthProcessDto({ - jwt, - redirect, - }); - - return response; - } - - async migrate( - userJwt: string, - currentUserId: string, - query: AuthorizationParams, - cachedState: OauthLoginStateDto - ): Promise { - const { state, systemId, userLoginMigration } = cachedState; - - if (state !== query.state) { - throw new UnauthorizedException(`Invalid state. Got: ${query.state} Expected: ${state}`); - } - - const redirectUri: string = this.oauthService.getRedirectUri(userLoginMigration); - - const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser( - systemId, - redirectUri, - query.code, - query.error - ); - - const data: OauthDataDto = await this.provisioningService.getData(systemId, tokenDto.idToken, tokenDto.accessToken); - - if (data.externalSchool) { - const schoolToMigrate: LegacySchoolDo | null = await this.schoolMigrationService.schoolToMigrate( - currentUserId, - data.externalSchool.externalId, - data.externalSchool.officialSchoolNumber - ); - if (schoolToMigrate) { - await this.schoolMigrationService.migrateSchool(data.externalSchool.externalId, schoolToMigrate, systemId); - } - } - - const migrationDto: MigrationDto = await this.userMigrationService.migrateUser( - currentUserId, - data.externalUser.externalId, - systemId - ); - - await this.authenticationService.removeJwtFromWhitelist(userJwt); - - return migrationDto; - } - - private async getJwtForUser(userId: EntityId): Promise { - const oauthCurrentUser: OauthCurrentUser = await this.userService.getResolvedUser(userId); - - const { accessToken } = await this.authenticationService.generateJwt(oauthCurrentUser); - - return accessToken; - } -} diff --git a/apps/server/src/modules/provisioning/dto/external-school.dto.ts b/apps/server/src/modules/provisioning/dto/external-school.dto.ts index c853c090228..701ee63f931 100644 --- a/apps/server/src/modules/provisioning/dto/external-school.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-school.dto.ts @@ -5,9 +5,12 @@ export class ExternalSchoolDto { officialSchoolNumber?: string; + location?: string; + constructor(props: ExternalSchoolDto) { this.externalId = props.externalId; this.name = props.name; this.officialSchoolNumber = props.officialSchoolNumber; + this.location = props.location; } } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index 09e253dddbf..5fcd5fc37a5 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -1,28 +1,29 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountService } from '@modules/account/services/account.service'; +import { AccountSaveDto } from '@modules/account/services/dto'; +import { Group, GroupService } from '@modules/group'; +import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { RoleService } from '@modules/role'; +import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { UserService } from '@modules/user'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalSource, LegacySchoolDo, RoleName, RoleReference, SchoolFeatures } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { externalGroupDtoFactory, federalStateFactory, groupFactory, - roleDtoFactory, legacySchoolDoFactory, + roleDtoFactory, + roleFactory, schoolYearFactory, userDoFactory, - roleFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountSaveDto } from '@modules/account/services/dto'; -import { Group, GroupService } from '@modules/group'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; -import { UserService } from '@modules/user'; import CryptoJS from 'crypto-js'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; import { OidcProvisioningService } from './oidc-provisioning.service'; @@ -101,102 +102,414 @@ describe('OidcProvisioningService', () => { }); describe('provisionExternalSchool', () => { - const setup = () => { - const systemId = 'systemId'; - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + describe('when systemId is given and external school does not exist', () => { + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should save the correct data', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); + + it('should save the new school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [systemId], - features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); }); - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(null); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYearFactory.build()); - federalStateService.findFederalStateByName.mockResolvedValue(federalStateFactory.build()); + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); - describe('when systemId is given and external school does not exist', () => { - it('should save the new school', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - expect(result).toEqual(savedSchoolDO); + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); }); }); describe('when external school already exist', () => { - it('should update the existing school', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + it('should update the existing school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - expect(result).toEqual(savedSchoolDO); + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); }); - it('should append the new system', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); - const otherSystemId = 'otherSystemId'; - existingSchoolDO.systems = [otherSystemId]; + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); - await service.provisionExternalSchool(externalSchoolDto, systemId); + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - expect(schoolService.save).toHaveBeenCalledWith( - { - ...savedSchoolDO, - systems: [otherSystemId, systemId], - }, - true - ); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); }); - it('should create a new system list', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); - existingSchoolDO.systems = undefined; + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - await service.provisionExternalSchool(externalSchoolDto, systemId); + await service.provisionExternalSchool(externalSchoolDto, systemId); - expect(schoolService.save).toHaveBeenCalledWith( - { - ...savedSchoolDO, - federalState: { - ...savedSchoolDO.federalState, - createdAt: expect.any(Date), - updatedAt: expect.any(Date), + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); + }); + + describe('when there is a system at the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const otherSystemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [otherSystemId, systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [otherSystemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + otherSystemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append the new system', async () => { + const { systemId, otherSystemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith( + { + ...savedSchoolDO, + systems: [otherSystemId, systemId], }, - inMaintenanceSince: expect.any(Date), - }, - true - ); + true + ); + }); + }); + + describe('when there is no system at the school yet', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should create a new system list', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 66c243e6457..6d13537439b 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -1,7 +1,3 @@ -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, ExternalSource, FederalStateEntity, SchoolFeatures, SchoolYearEntity } from '@shared/domain'; -import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { Logger } from '@src/core/logger'; import { AccountService } from '@modules/account/services/account.service'; import { AccountSaveDto } from '@modules/account/services/dto'; import { Group, GroupService, GroupUser } from '@modules/group'; @@ -10,9 +6,13 @@ import { FederalStateNames } from '@modules/legacy-school/types'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, ExternalSource, FederalStateEntity, SchoolFeatures, SchoolYearEntity } from '@shared/domain'; +import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; +import { Logger } from '@src/core/logger'; import { ObjectId } from 'bson'; import CryptoJS from 'crypto-js'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @@ -37,7 +37,7 @@ export class OidcProvisioningService { let school: LegacySchoolDo; if (existingSchool) { school = existingSchool; - school.name = externalSchool.name; + school.name = this.getSchoolName(externalSchool); school.officialSchoolNumber = externalSchool.officialSchoolNumber ?? existingSchool.officialSchoolNumber; if (!school.systems) { school.systems = [systemId]; @@ -52,7 +52,7 @@ export class OidcProvisioningService { school = new LegacySchoolDo({ externalId: externalSchool.externalId, - name: externalSchool.name, + name: this.getSchoolName(externalSchool), officialSchoolNumber: externalSchool.officialSchoolNumber, systems: [systemId], features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], @@ -63,9 +63,18 @@ export class OidcProvisioningService { } const savedSchool: LegacySchoolDo = await this.schoolService.save(school, true); + return savedSchool; } + private getSchoolName(externalSchool: ExternalSchoolDto): string { + const schoolName: string = externalSchool.location + ? `${externalSchool.name} (${externalSchool.location})` + : externalSchool.name; + + return schoolName; + } + async provisionExternalUser(externalUser: ExternalUserDto, systemId: EntityId, schoolId?: string): Promise { let roleRefs: RoleReference[] | undefined; if (externalUser.roles) { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts index 56f70ad0f41..bb8cc6e8d07 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts @@ -11,3 +11,4 @@ export * from './sanis-personenkontext-response'; export * from './sanis-gruppenzugehoerigkeit-response'; export * from './sanis-person-response'; export * from './sanis-sonstige-gruppenzugehoerige-response'; +export * from './sanis-anschrift-response'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts new file mode 100644 index 00000000000..6b793ba4486 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts @@ -0,0 +1,9 @@ +export interface SanisAnschriftResponse { + adresszeile?: string; + + postleitzahl?: string; + + ort?: string; + + ortsteil?: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts index 258cde00a50..fa7d2846ad1 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts @@ -1,3 +1,5 @@ +import { SanisAnschriftResponse } from './sanis-anschrift-response'; + export interface SanisOrganisationResponse { id: string; @@ -6,4 +8,6 @@ export interface SanisOrganisationResponse { name: string; typ: string; + + anschrift?: SanisAnschriftResponse; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index 2fe68c0163b..9829e3cb930 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; +import { GroupTypes } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { GroupTypes } from '@modules/group'; import { UUID } from 'bson'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { @@ -56,6 +56,9 @@ describe('SanisResponseMapper', () => { name: 'schoolName', typ: 'SCHULE', kennung: 'NI_123456_NI_ashd3838', + anschrift: { + ort: 'Hannover', + }, }, personenstatus: '', gruppen: [ @@ -103,6 +106,7 @@ describe('SanisResponseMapper', () => { externalId: externalSchoolId, name: 'schoolName', officialSchoolNumber: '123456_NI_ashd3838', + location: 'Hannover', }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 23d9b15fbdc..ee912dd67b5 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -1,7 +1,7 @@ +import { GroupTypes } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { RoleName } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { GroupTypes } from '@modules/group'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { GroupRoleUnknownLoggable } from '../../loggable'; import { @@ -45,6 +45,7 @@ export class SanisResponseMapper { name: source.personenkontexte[0].organisation.name, externalId: source.personenkontexte[0].organisation.id.toString(), officialSchoolNumber, + location: source.personenkontexte[0].organisation.anschrift?.ort, }); return mapped; diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts index 9d3711aff02..324e444afcb 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts @@ -3,7 +3,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Page, Pseudonym } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, externalToolPseudonymEntityFactory, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts index 548ba1b0512..8246479da4a 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts @@ -3,7 +3,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Pseudonym } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/rocketchat-user/domain/index.ts b/apps/server/src/modules/rocketchat-user/domain/index.ts new file mode 100644 index 00000000000..0246dd0f0f9 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.do'; diff --git a/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts new file mode 100644 index 00000000000..a1be448f80c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts @@ -0,0 +1,67 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUser } from './rocket-chat-user.do'; +import { rocketChatUserFactory } from './testing/rocket-chat-user.factory'; + +describe(RocketChatUser.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a rocketChatUser by passing required properties', () => { + const domainObject: RocketChatUser = rocketChatUserFactory.build(); + + expect(domainObject instanceof RocketChatUser).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: RocketChatUser = rocketChatUserFactory.build(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const rocketChatUserObject: RocketChatUser = new RocketChatUser(domainObject); + + expect(rocketChatUserObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + username: 'Test.User.shls', + rcId: 'JfMJXua6t29KYXdDc', + authToken: 'OL8e5YCZHy3agGnLS-gHAx1wU4ZCG8-DXU_WZnUxUu6', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const rocketChatUserDo = new RocketChatUser(props); + + return { props, rocketChatUserDo }; + }; + + it('getters should return proper values', () => { + const { props, rocketChatUserDo } = setup(); + + const gettersValues = { + id: rocketChatUserDo.id, + userId: rocketChatUserDo.userId, + username: rocketChatUserDo.username, + rcId: rocketChatUserDo.rcId, + authToken: rocketChatUserDo.authToken, + createdAt: rocketChatUserDo.createdAt, + updatedAt: rocketChatUserDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts new file mode 100644 index 00000000000..8dfd830f3eb --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts @@ -0,0 +1,37 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; + +export interface RocketChatUserProps extends AuthorizableObject { + userId: EntityId; + username: string; + rcId: string; + authToken?: string; + createdAt?: Date; + updatedAt?: Date; +} + +export class RocketChatUser extends DomainObject { + get userId(): EntityId { + return this.props.userId; + } + + get username(): string { + return this.props.username; + } + + get rcId(): string { + return this.props.rcId; + } + + get authToken(): string | undefined { + return this.props.authToken; + } + + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } +} diff --git a/apps/server/src/modules/rocketchat-user/domain/testing/index.ts b/apps/server/src/modules/rocketchat-user/domain/testing/index.ts new file mode 100644 index 00000000000..2ef434c0975 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/testing/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.factory'; diff --git a/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts b/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts new file mode 100644 index 00000000000..3ad6432d1d5 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RocketChatUser, RocketChatUserProps } from '../rocket-chat-user.do'; + +export const rocketChatUserFactory = BaseFactory.define( + RocketChatUser, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + username: `username-${sequence}`, + rcId: `rcId-${sequence}`, + authToken: `aythToken-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/rocketchat-user/entity/index.ts b/apps/server/src/modules/rocketchat-user/entity/index.ts new file mode 100644 index 00000000000..9528e8da500 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.entity'; diff --git a/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts new file mode 100644 index 00000000000..f8d5318c5bf --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts @@ -0,0 +1,61 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; + +describe(RocketChatUserEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + userId: new ObjectId(), + username: 'Test.User.shls', + rcId: 'JfMJXua6t29KYXdDc', + authToken: 'OL8e5YCZHy3agGnLS-gHAx1wU4ZCG8-DXU_WZnUxUu6', + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new RocketChatUserEntity(); + expect(test).toThrow(); + }); + + it('should create a rocketChatUser by passing required properties', () => { + const { props } = setup(); + const entity: RocketChatUserEntity = new RocketChatUserEntity(props); + + expect(entity instanceof RocketChatUserEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: RocketChatUserEntity = new RocketChatUserEntity(props); + + const entityProps = { + id: entity.id, + userId: entity.userId, + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts new file mode 100644 index 00000000000..6df469e0ddb --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts @@ -0,0 +1,56 @@ +import { Entity, Index, Property, Unique } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; + +export interface RocketChatUserEntityProps { + id?: EntityId; + userId: ObjectId; + username: string; + rcId: string; + authToken?: string; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'rocketchatuser' }) +export class RocketChatUserEntity extends BaseEntityWithTimestamps { + @Property() + @Unique() + username: string; + + @Property() + @Unique() + userId: ObjectId; + + @Property() + @Index() + rcId: string; + + @Property({ nullable: true }) + authToken?: string; + + constructor(props: RocketChatUserEntityProps) { + super(); + + if (props.id !== undefined) { + this.id = props.id; + } + + this.userId = props.userId; + this.username = props.username; + this.rcId = props.rcId; + + if (props.authToken !== undefined) { + this.authToken = props.authToken; + } + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } +} diff --git a/apps/server/src/modules/rocketchat-user/entity/testing/index.ts b/apps/server/src/modules/rocketchat-user/entity/testing/index.ts new file mode 100644 index 00000000000..f19ebd8c74a --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.entity.factory'; diff --git a/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts b/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts new file mode 100644 index 00000000000..302459a4eb6 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts @@ -0,0 +1,20 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RocketChatUserEntity, RocketChatUserEntityProps } from '../rocket-chat-user.entity'; + +class RocketChatUserFactory extends BaseFactory {} + +export const rocketChatUserEntityFactory = RocketChatUserFactory.define< + RocketChatUserEntity, + RocketChatUserEntityProps +>(RocketChatUserEntity, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + userId: new ObjectId(), + username: `username-${sequence}`, + rcId: `rcId-${sequence}`, + authToken: `aythToken-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/rocketchat-user/index.ts b/apps/server/src/modules/rocketchat-user/index.ts new file mode 100644 index 00000000000..34ae0f25f87 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/index.ts @@ -0,0 +1,3 @@ +export * from './rocketchat-user.module'; +export * from './service'; +export * from './domain'; diff --git a/apps/server/src/modules/rocketchat-user/repo/index.ts b/apps/server/src/modules/rocketchat-user/repo/index.ts new file mode 100644 index 00000000000..b05b92fc380 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.repo'; diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts new file mode 100644 index 00000000000..7a33e93289e --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.mapper'; diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts new file mode 100644 index 00000000000..bd5a07abb5c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts @@ -0,0 +1,61 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUser } from '../../domain/rocket-chat-user.do'; +import { rocketChatUserFactory } from '../../domain/testing/rocket-chat-user.factory'; +import { RocketChatUserEntity } from '../../entity'; +import { rocketChatUserEntityFactory } from '../../entity/testing/rocket-chat-user.entity.factory'; +import { RocketChatUserMapper } from './rocket-chat-user.mapper'; + +describe(RocketChatUserMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + it('should properly map the entity to the domain object', () => { + const entity = rocketChatUserEntityFactory.build(); + + const domainObject = RocketChatUserMapper.mapToDO(entity); + + const expectedDomainObject = new RocketChatUser({ + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should properly map the domainObject to the entity', () => { + const domainObject = rocketChatUserFactory.build(); + + const entity = RocketChatUserMapper.mapToEntity(domainObject); + + const expectedEntity = new RocketChatUserEntity({ + id: domainObject.id, + userId: new ObjectId(domainObject.userId), + username: domainObject.username, + rcId: domainObject.rcId, + authToken: domainObject.authToken, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + expect(entity).toEqual(expectedEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts new file mode 100644 index 00000000000..3d45c9c34ac --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts @@ -0,0 +1,29 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUserEntity } from '../../entity'; +import { RocketChatUser } from '../../domain/rocket-chat-user.do'; + +export class RocketChatUserMapper { + static mapToDO(entity: RocketChatUserEntity): RocketChatUser { + return new RocketChatUser({ + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + } + + static mapToEntity(domainObject: RocketChatUser): RocketChatUserEntity { + return new RocketChatUserEntity({ + id: domainObject.id, + userId: new ObjectId(domainObject.userId), + username: domainObject.username, + rcId: domainObject.rcId, + authToken: domainObject.authToken, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + } +} diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts new file mode 100644 index 00000000000..d58e5fc42d1 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts @@ -0,0 +1,131 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { RocketChatUserMapper } from './mapper'; +import { RocketChatUserEntity } from '../entity'; +import { RocketChatUserRepo } from './rocket-chat-user.repo'; +import { RocketChatUser } from '../domain'; +import { rocketChatUserEntityFactory } from '../entity/testing'; + +describe(RocketChatUserRepo.name, () => { + let module: TestingModule; + let repo: RocketChatUserRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [RocketChatUserEntity], + }), + ], + providers: [RocketChatUserRepo, RocketChatUserMapper], + }).compile(); + + repo = module.get(RocketChatUserRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + expect(typeof repo.findByUserId).toEqual('function'); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(RocketChatUserEntity); + }); + }); + + describe('findByUserId', () => { + describe('when searching rocketChatUser by userId', () => { + const setup = async () => { + const userId = new ObjectId(); + const entity: RocketChatUserEntity = rocketChatUserEntityFactory.build({ userId }); + await em.persistAndFlush(entity); + em.clear(); + const expectedRocketChatUser = { + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedRocketChatUser, + }; + }; + + it('should find the rocketChatUser', async () => { + const { entity, expectedRocketChatUser } = await setup(); + + const result: RocketChatUser = await repo.findByUserId(entity.userId.toHexString()); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedRocketChatUser)); + }); + }); + }); + + describe('deleteByUserId', () => { + describe('when deleting rocketChatUser exists', () => { + const setup = async () => { + const entity: RocketChatUserEntity = rocketChatUserEntityFactory.build(); + const rocketChatUserId = entity.userId.toHexString(); + await em.persistAndFlush(entity); + em.clear(); + + return { rocketChatUserId }; + }; + + it('should delete the rocketChatUSer with userId', async () => { + const { rocketChatUserId } = await setup(); + + await repo.deleteByUserId(rocketChatUserId); + + expect(await em.findOne(RocketChatUserEntity, { userId: new ObjectId(rocketChatUserId) })).toBeNull(); + }); + + it('should return number equal 1', async () => { + const { rocketChatUserId } = await setup(); + + const result: number = await repo.deleteByUserId(rocketChatUserId); + + expect(result).toEqual(1); + }); + }); + + describe('when no rocketChatUser exists', () => { + const setup = () => { + const rocketChatUserId = new ObjectId().toHexString(); + + return { rocketChatUserId }; + }; + + it('should return false', async () => { + const { rocketChatUserId } = setup(); + + const result: number = await repo.deleteByUserId(rocketChatUserId); + + expect(result).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts new file mode 100644 index 00000000000..741f297f804 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts @@ -0,0 +1,33 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { RocketChatUserEntity } from '../entity'; +import { RocketChatUser } from '../domain/rocket-chat-user.do'; +import { RocketChatUserMapper } from './mapper'; + +@Injectable() +export class RocketChatUserRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return RocketChatUserEntity; + } + + async findByUserId(userId: EntityId): Promise { + const entity: RocketChatUserEntity = await this.em.findOneOrFail(RocketChatUserEntity, { + userId: new ObjectId(userId), + }); + + const mapped: RocketChatUser = RocketChatUserMapper.mapToDO(entity); + + return mapped; + } + + async deleteByUserId(userId: EntityId): Promise { + const promise: Promise = this.em.nativeDelete(RocketChatUserEntity, { + userId: new ObjectId(userId), + }); + + return promise; + } +} diff --git a/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts new file mode 100644 index 00000000000..798b2276a4d --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RocketChatUserRepo } from './repo'; +import { RocketChatUserService } from './service/rocket-chat-user.service'; +import { RocketChatService } from '../rocketchat/rocket-chat.service'; + +@Module({ + providers: [RocketChatUserService, RocketChatUserRepo], + exports: [RocketChatService], +}) +export class RocketChatUserModule {} diff --git a/apps/server/src/modules/rocketchat-user/service/index.ts b/apps/server/src/modules/rocketchat-user/service/index.ts new file mode 100644 index 00000000000..350217d4e38 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.service'; diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts new file mode 100644 index 00000000000..dd8ae17667c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -0,0 +1,94 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { RocketChatUserService } from './rocket-chat-user.service'; +import { RocketChatUserRepo } from '../repo'; +import { rocketChatUserFactory } from '../domain/testing/rocket-chat-user.factory'; +import { RocketChatUser } from '../domain'; + +describe(RocketChatUserService.name, () => { + let module: TestingModule; + let service: RocketChatUserService; + let rocketChatUserRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RocketChatUserService, + { + provide: RocketChatUserRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RocketChatUserService); + rocketChatUserRepo = module.get(RocketChatUserRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findByUserId', () => { + describe('when searching rocketChatUser', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build(); + + rocketChatUserRepo.findByUserId.mockResolvedValueOnce(rocketChatUser); + + return { + userId, + rocketChatUser, + }; + }; + + it('should return the rocketChatUser', async () => { + const { userId, rocketChatUser } = setup(); + + const result: RocketChatUser = await service.findByUserId(userId); + + expect(result).toEqual(rocketChatUser); + }); + }); + }); + + describe('deleteUserDataFromClasses', () => { + describe('when deleting rocketChatUser', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + rocketChatUserRepo.deleteByUserId.mockResolvedValueOnce(1); + + return { + userId, + }; + }; + + it('should call rocketChatUserRepo', async () => { + const { userId } = setup(); + + await service.deleteByUserId(userId); + + expect(rocketChatUserRepo.deleteByUserId).toBeCalledWith(userId); + }); + + it('should delete rocketChatUser by userId', async () => { + const { userId } = setup(); + + const result: number = await service.deleteByUserId(userId); + + expect(result).toEqual(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts new file mode 100644 index 00000000000..32a600c0f75 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { RocketChatUserRepo } from '../repo'; +import { RocketChatUser } from '../domain'; + +@Injectable() +export class RocketChatUserService { + constructor(private readonly rocketChatUserRepo: RocketChatUserRepo) {} + + public async findByUserId(userId: EntityId): Promise { + const user: RocketChatUser = await this.rocketChatUserRepo.findByUserId(userId); + + return user; + } + + public deleteByUserId(userId: EntityId): Promise { + return this.rocketChatUserRepo.deleteByUserId(userId); + } +} diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 09dd2210928..5d1ea95cc3b 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -1,10 +1,11 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import type { IIdentityManagementConfig } from '@shared/infra/identity-management'; +import type { IIdentityManagementConfig } from '@infra/identity-management'; import type { ICoreModuleConfig } from '@src/core'; import type { IAccountConfig } from '@modules/account'; import type { IFilesStorageClientConfig } from '@modules/files-storage-client'; import type { IUserConfig } from '@modules/user'; import type { ICommonCartridgeConfig } from '@modules/learnroom/common-cartridge'; +import { IMailConfig } from '@src/infra/mail/interfaces/mail-config'; export enum NodeEnvType { TEST = 'test', @@ -19,7 +20,8 @@ export interface IServerConfig IFilesStorageClientConfig, IAccountConfig, IIdentityManagementConfig, - ICommonCartridgeConfig { + ICommonCartridgeConfig, + IMailConfig { NODE_ENV: string; SC_DOMAIN: string; } @@ -39,6 +41,9 @@ const config: IServerConfig = { FEATURE_IDENTITY_MANAGEMENT_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean, + ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: (Configuration.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS') as string) + .split(',') + .map((domain) => domain.trim()), }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 9454fa06154..a812ff773b2 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -28,10 +28,10 @@ import { VideoConferenceApiModule } from '@modules/video-conference/video-confer import { DynamicModule, Inject, MiddlewareConsumer, Module, NestModule, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain'; -import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { MailModule } from '@shared/infra/mail'; -import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq'; -import { RedisModule, REDIS_CLIENT } from '@shared/infra/redis'; +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { MailModule } from '@infra/mail'; +import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { RedisModule, REDIS_CLIENT } from '@infra/redis'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; diff --git a/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts b/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts index 4bd73e6c2a8..f5e01b0e9ea 100644 --- a/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts +++ b/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, schoolFactory, shareTokenFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ShareTokenContextType } from '../domainobject/share-token.do'; diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index ead44bf6133..89ef533058b 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { OauthConfig, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@shared/infra/identity-management'; +import { IdentityManagementOauthService } from '@infra/identity-management'; import { SystemRepo } from '@shared/repo'; import { systemFactory } from '@shared/testing'; import { SystemMapper } from '../mapper/system.mapper'; diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index 960c15f7945..bfb6a2ec7bf 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@shared/infra/identity-management/identity-management-oauth.service'; +import { IdentityManagementOauthService } from '@infra/identity-management/identity-management-oauth.service'; import { SystemRepo } from '@shared/repo'; import { SystemMapper } from '@modules/system/mapper/system.mapper'; import { SystemDto } from '@modules/system/service/dto/system.dto'; diff --git a/apps/server/src/modules/system/system.module.ts b/apps/server/src/modules/system/system.module.ts index 64caef0df61..37ca8d7a858 100644 --- a/apps/server/src/modules/system/system.module.ts +++ b/apps/server/src/modules/system/system.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { IdentityManagementModule } from '@shared/infra/identity-management/identity-management.module'; +import { IdentityManagementModule } from '@infra/identity-management/identity-management.module'; import { SystemRepo } from '@shared/repo'; import { SystemService } from '@modules/system/service/system.service'; import { SystemOidcService } from './service/system-oidc.service'; diff --git a/apps/server/src/modules/task/service/submission.service.spec.ts b/apps/server/src/modules/task/service/submission.service.spec.ts index 4d7373570cf..abf5ed9f152 100644 --- a/apps/server/src/modules/task/service/submission.service.spec.ts +++ b/apps/server/src/modules/task/service/submission.service.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Counted, Submission } from '@shared/domain'; -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { SubmissionRepo } from '@shared/repo'; import { setupEntities, submissionFactory, taskFactory } from '@shared/testing'; import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts index 43a2aba1a39..d3ca547a854 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts @@ -597,6 +597,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const undefinedRegex: CustomParameter = customParameterFactory.build({ name: 'undefinedRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: undefined, @@ -629,6 +630,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: '[x]', @@ -661,6 +663,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: '[x]', @@ -688,6 +691,39 @@ describe('CommonToolValidationService', () => { expect(func).toThrowError('tool_param_value_regex'); }); }); + + describe('when parameter is optional and a regex is given, but the param value is undefined', () => { + const setup = () => { + const optionalRegex: CustomParameter = customParameterFactory.build({ + name: 'optionalRegex', + isOptional: true, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: '[x]', + }); + const { externalTool, schoolExternalTool } = createTools( + { + parameters: [optionalRegex], + }, + { + parameters: [{ name: 'optionalRegex', value: undefined }], + } + ); + + return { + externalTool, + schoolExternalTool, + }; + }; + + it('should return without error', () => { + const { externalTool, schoolExternalTool } = setup(); + + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); + + expect(func).not.toThrowError('tool_param_value_regex'); + }); + }); }); }); }); diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts index 9d315a97f34..3ee9ee7d465 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts @@ -87,7 +87,7 @@ export class CommonToolValidationService { } private checkParameterRegex(foundEntry: CustomParameterEntry, param: CustomParameter): void { - if (param.regex && !new RegExp(param.regex).test(foundEntry.value ?? '')) { + if (foundEntry.value !== undefined && param.regex && !new RegExp(param.regex).test(foundEntry.value ?? '')) { throw new ValidationError( `tool_param_value_regex: The given entry for the parameter with name ${foundEntry.name} does not fit the regex.` ); diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts index 6fdbad2b3af..aebab1a8d5d 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts @@ -18,7 +18,7 @@ export interface IContextExternalToolProperties { toolVersion: number; } -@Entity({ tableName: 'context_external_tools' }) +@Entity({ tableName: 'context-external-tools' }) export class ContextExternalToolEntity extends BaseEntityWithTimestamps { @ManyToOne() schoolTool: SchoolExternalToolEntity; diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 3bd3ed9c30d..481ed3b7c2d 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -6,7 +6,7 @@ import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } export type IExternalToolProperties = Readonly>; -@Entity({ tableName: 'external_tools' }) +@Entity({ tableName: 'external-tools' }) export class ExternalToolEntity extends BaseEntityWithTimestamps { @Unique() @Property() diff --git a/apps/server/src/modules/tool/external-tool/external-tool.module.ts b/apps/server/src/modules/tool/external-tool/external-tool.module.ts index 7db5c25a252..2fbd2f28edd 100644 --- a/apps/server/src/modules/tool/external-tool/external-tool.module.ts +++ b/apps/server/src/modules/tool/external-tool/external-tool.module.ts @@ -1,8 +1,8 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { OauthProviderServiceModule } from '@shared/infra/oauth-provider'; -import { EncryptionModule } from '@shared/infra/encryption'; +import { OauthProviderServiceModule } from '@infra/oauth-provider'; +import { EncryptionModule } from '@infra/encryption'; import { ExternalToolRepo } from '@shared/repo'; import { ToolConfigModule } from '../tool-config.module'; import { diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts index 53a07c02e30..fb506a22f8c 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; import { TokenEndpointAuthMethod, ToolConfigType } from '../../common/enum'; import { Oauth2ToolConfig } from '../domain'; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts index 31ff93db828..c531f33c483 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts @@ -1,4 +1,4 @@ -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { Injectable } from '@nestjs/common'; import { Oauth2ToolConfig } from '../domain'; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts index 434e7fac86e..bf768a83a3e 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ExternalToolLogoService } from './external-tool-logo.service'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; @@ -11,7 +10,6 @@ export class ExternalToolValidationService { constructor( private readonly externalToolService: ExternalToolService, private readonly externalToolParameterValidationService: ExternalToolParameterValidationService, - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, private readonly externalToolLogoService: ExternalToolLogoService ) {} diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts index 4db2a5be0b0..ddb88ca4ff3 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts @@ -2,9 +2,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, Page, SortOrder } from '@shared/domain'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { externalToolFactory, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts index fcc1a7e2d5c..0aa6181682c 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts @@ -1,8 +1,8 @@ import { Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; import { EntityId, IFindOptions, Page } from '@shared/domain'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; -import { OauthProviderService } from '@shared/infra/oauth-provider'; -import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; +import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; +import { OauthProviderService } from '@infra/oauth-provider'; +import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { TokenEndpointAuthMethod } from '../../common/enum'; diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts index a1682e3b7cd..fc7f6703d05 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -11,7 +11,7 @@ export interface ISchoolExternalToolProperties { toolVersion: number; } -@Entity({ tableName: 'school_external_tools' }) +@Entity({ tableName: 'school-external-tools' }) export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { @ManyToOne() tool: ExternalToolEntity; diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index 3c3baa56475..ff4aa4c266c 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -16,7 +16,7 @@ import { SystemEntity, User, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; import { federalStateFactory, importUserFactory, schoolFactory, userFactory } from '@shared/testing'; import { systemFactory } from '@shared/testing/factory/system.factory'; diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index 223fa1f0c88..f65d02c13a5 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -7,12 +7,11 @@ import { UserDO } from '@shared/domain/domainobject/user.do'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; -import { RoleService } from '@modules/role/service/role.service'; -import { UserService } from '@modules/user/service/user.service'; -import { UserDto } from '@modules/user/uc/dto/user.dto'; +import { AccountService, AccountDto } from '@modules/account'; +import { RoleService } from '@modules/role'; import { OauthCurrentUser } from '@modules/authentication/interface'; +import { UserDto } from '../uc/dto/user.dto'; +import { UserService } from './user.service'; import { UserQuery } from './user-query.type'; describe('UserService', () => { diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index c7a1ed30668..28711a06c1a 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -12,7 +12,7 @@ import { VideoConferenceDO, VideoConferenceScope, } from '@shared/domain'; -import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; +import { CalendarEventDto, CalendarService } from '@infra/calendar'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.ts b/apps/server/src/modules/video-conference/service/video-conference.service.ts index 22e7a7462f1..401cc0a0015 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.ts @@ -14,7 +14,7 @@ import { VideoConferenceOptionsDO, VideoConferenceScope, } from '@shared/domain'; -import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; +import { CalendarEventDto, CalendarService } from '@infra/calendar'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { CourseService } from '@modules/learnroom'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts index 994c8042a6d..bf1fd3e1394 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts @@ -14,8 +14,8 @@ import { VideoConferenceDO, } from '@shared/domain'; import { VideoConferenceScope } from '@shared/domain/interface'; -import { CalendarService } from '@shared/infra/calendar'; -import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto'; +import { CalendarService } from '@infra/calendar'; +import { CalendarEventDto } from '@infra/calendar/dto/calendar-event.dto'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; import { roleFactory, setupEntities, userDoFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts index 41e011c4acd..95470053e6d 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts @@ -13,8 +13,8 @@ import { VideoConferenceOptionsDO, } from '@shared/domain'; import { VideoConferenceScope } from '@shared/domain/interface'; -import { CalendarService } from '@shared/infra/calendar'; -import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto'; +import { CalendarService } from '@infra/calendar'; +import { CalendarEventDto } from '@infra/calendar/dto/calendar-event.dto'; import { TeamsRepo } from '@shared/repo'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { ICurrentUser } from '@modules/authentication'; diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index c9708b16dc9..d7e4671c0f2 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; -import { CalendarModule } from '@shared/infra/calendar'; +import { CalendarModule } from '@infra/calendar'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; diff --git a/apps/server/src/shared/domain/entity/all-entities.spec.ts b/apps/server/src/shared/domain/entity/all-entities.spec.ts index 33d924b20ce..b7a27fd58b4 100644 --- a/apps/server/src/shared/domain/entity/all-entities.spec.ts +++ b/apps/server/src/shared/domain/entity/all-entities.spec.ts @@ -1,7 +1,7 @@ import { MikroORM } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ALL_ENTITIES } from '.'; describe('BaseRepo', () => { diff --git a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts index 2daf9707f3c..b79b3d862c3 100644 --- a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts +++ b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts @@ -5,7 +5,7 @@ import { BaseEntityWithTimestamps } from './base.entity'; export type IUserLoginMigration = Readonly>; -@Entity({ tableName: 'user_login_migrations' }) +@Entity({ tableName: 'user-login-migrations' }) export class UserLoginMigrationEntity extends BaseEntityWithTimestamps { @OneToOne(() => SchoolEntity, undefined, { nullable: false }) school: SchoolEntity; diff --git a/apps/server/src/shared/infra/collaborative-storage/index.ts b/apps/server/src/shared/infra/collaborative-storage/index.ts deleted file mode 100644 index ea5aa25514c..00000000000 --- a/apps/server/src/shared/infra/collaborative-storage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './collaborative-storage.adapter'; diff --git a/apps/server/src/shared/infra/mail/mail.service.spec.ts b/apps/server/src/shared/infra/mail/mail.service.spec.ts deleted file mode 100644 index 58c0ce9336a..00000000000 --- a/apps/server/src/shared/infra/mail/mail.service.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Mail } from './mail.interface'; -import { MailService } from './mail.service'; - -describe('MailService', () => { - let module: TestingModule; - let service: MailService; - let amqpConnection: AmqpConnection; - - const mailServiceOptions = { - exchange: 'exchange', - routingKey: 'routingKey', - }; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - MailService, - { provide: AmqpConnection, useValue: { publish: () => {} } }, - { provide: 'MAIL_SERVICE_OPTIONS', useValue: mailServiceOptions }, - ], - }).compile(); - - service = module.get(MailService); - amqpConnection = module.get(AmqpConnection); - }); - - afterAll(async () => { - await module.close(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - it('should send given data to queue', async () => { - const data: Mail = { mail: { plainTextContent: 'content', subject: 'Test' }, recipients: ['test@example.com'] }; - const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); - - await service.send(data); - - const expectedParams = [mailServiceOptions.exchange, mailServiceOptions.routingKey, data, { persistent: true }]; - expect(amqpConnectionSpy).toHaveBeenCalledWith(...expectedParams); - }); -}); diff --git a/apps/server/src/shared/infra/mail/mail.service.ts b/apps/server/src/shared/infra/mail/mail.service.ts deleted file mode 100644 index aaf9cfacb9d..00000000000 --- a/apps/server/src/shared/infra/mail/mail.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; -import { Inject, Injectable } from '@nestjs/common'; - -import { Mail } from './mail.interface'; - -interface MailServiceOptions { - exchange: string; - routingKey: string; -} - -@Injectable() -export class MailService { - constructor( - private readonly amqpConnection: AmqpConnection, - @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions - ) {} - - public async send(data: Mail): Promise { - await this.amqpConnection.publish(this.options.exchange, this.options.routingKey, data, { persistent: true }); - } -} diff --git a/apps/server/src/shared/repo/base.do.repo.integration.spec.ts b/apps/server/src/shared/repo/base.do.repo.integration.spec.ts index 563940a571e..1ca91bd184e 100644 --- a/apps/server/src/shared/repo/base.do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/base.do.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Entity, EntityName, Property } from '@mikro-orm/core'; import { BaseDO, BaseEntityWithTimestamps } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { Injectable } from '@nestjs/common'; import { BaseDORepo } from '@shared/repo/base.do.repo'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/shared/repo/base.repo.integration.spec.ts b/apps/server/src/shared/repo/base.repo.integration.spec.ts index 0c0e041f575..372a6ba4cda 100644 --- a/apps/server/src/shared/repo/base.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/base.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Entity, EntityName, Property } from '@mikro-orm/core'; import { BaseEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { Injectable } from '@nestjs/common'; import { BaseRepo } from './base.repo'; diff --git a/apps/server/src/shared/repo/board/board.repo.spec.ts b/apps/server/src/shared/repo/board/board.repo.spec.ts index 51ce325e90a..7d5f4b71e41 100644 --- a/apps/server/src/shared/repo/board/board.repo.spec.ts +++ b/apps/server/src/shared/repo/board/board.repo.spec.ts @@ -10,7 +10,7 @@ import { cleanupCollections, } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { BoardRepo } from './board.repo'; diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts index 0a9151d8c9c..854c3958135 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; import { cleanupCollections, diff --git a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts index 5474c4ec19d..2a77ad1f078 100644 --- a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts @@ -3,7 +3,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Course, EntityId, SortOrder } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, courseFactory, courseGroupFactory, userFactory } from '@shared/testing'; import { CourseRepo } from './course.repo'; diff --git a/apps/server/src/shared/repo/coursegroup/coursegroup.repo.integration.spec.ts b/apps/server/src/shared/repo/coursegroup/coursegroup.repo.integration.spec.ts index 459a2add113..805088480fd 100644 --- a/apps/server/src/shared/repo/coursegroup/coursegroup.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/coursegroup/coursegroup.repo.integration.spec.ts @@ -1,6 +1,6 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityId, CourseGroup, Course } from '@shared/domain'; import { courseFactory, courseGroupFactory } from '@shared/testing'; import { CourseGroupRepo } from './coursegroup.repo'; diff --git a/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.spec.ts b/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.spec.ts index 4f242caecd8..32b35ced07e 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.spec.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.spec.ts @@ -1,6 +1,6 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { InternalServerErrorException } from '@nestjs/common'; import { DashboardEntity, diff --git a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts index 1b3e2aefd63..3ca95112f4d 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { DashboardEntity, DashboardGridElementModel, GridElement } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { courseFactory, userFactory } from '@shared/testing'; import { DashboardModelMapper } from './dashboard.model.mapper'; import { DashboardRepo } from './dashboard.repo'; diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index 56cba80ec69..1654da1b5b7 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, Page, SortOrder } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ExternalToolRepo, ExternalToolRepoMapper } from '@shared/repo'; import { cleanupCollections, externalToolEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/shared/repo/federalstate/federal-state.repo.spec.ts b/apps/server/src/shared/repo/federalstate/federal-state.repo.spec.ts index bfd6b6e358e..0b5742c7621 100644 --- a/apps/server/src/shared/repo/federalstate/federal-state.repo.spec.ts +++ b/apps/server/src/shared/repo/federalstate/federal-state.repo.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { FederalStateEntity } from '@shared/domain'; import { cleanupCollections, federalStateFactory } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { FederalStateRepo } from './federal-state.repo'; describe('FederalStateRepo', () => { diff --git a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts b/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts index 7f02bf4ea01..148984c393a 100644 --- a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts @@ -12,7 +12,7 @@ import { SchoolEntity, User, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ImportUserRepo } from '.'; describe('ImportUserRepo', () => { diff --git a/apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts b/apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts index 5143eb6fba8..eae071d55ae 100644 --- a/apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ComponentType, IComponentProperties, LessonEntity } from '@shared/domain'; import { cleanupCollections, courseFactory, lessonFactory, materialFactory, taskFactory } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { LessonRepo } from './lesson.repo'; diff --git a/apps/server/src/shared/repo/ltitool/ltitool.repo.spec.ts b/apps/server/src/shared/repo/ltitool/ltitool.repo.spec.ts index c8b8a7aea50..97fc81e9ea1 100644 --- a/apps/server/src/shared/repo/ltitool/ltitool.repo.spec.ts +++ b/apps/server/src/shared/repo/ltitool/ltitool.repo.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ILtiToolProperties, LtiTool } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { LtiPrivacyPermission, LtiRoleType } from '@shared/domain/entity/ltitool.entity'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { LtiToolRepo } from '@shared/repo/ltitool/ltitool.repo'; import { cleanupCollections } from '@shared/testing'; import { ltiToolFactory } from '@shared/testing/factory/ltitool.factory'; diff --git a/apps/server/src/shared/repo/materials/materials.repo.integration.spec.ts b/apps/server/src/shared/repo/materials/materials.repo.integration.spec.ts index 13556fad62c..0b6b26ff39f 100644 --- a/apps/server/src/shared/repo/materials/materials.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/materials/materials.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Material } from '@shared/domain/entity/materials.entity'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; import { MaterialsRepo } from './materials.repo'; diff --git a/apps/server/src/shared/repo/news/news-inheritance.spec.ts b/apps/server/src/shared/repo/news/news-inheritance.spec.ts index 53eb2aa4dd4..d9d5a003d5d 100644 --- a/apps/server/src/shared/repo/news/news-inheritance.spec.ts +++ b/apps/server/src/shared/repo/news/news-inheritance.spec.ts @@ -2,7 +2,7 @@ import { Collection, Entity, Enum, ManyToMany, ManyToOne, Property } from '@mikr import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { BaseEntityWithTimestamps } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; @Entity({ tableName: 'users' }) diff --git a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts index 93bff583f4e..d68e50e0df1 100644 --- a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { News, NewsTargetModel, SortOrder } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { courseNewsFactory, schoolNewsFactory, diff --git a/apps/server/src/shared/repo/role/role.repo.integration.spec.ts b/apps/server/src/shared/repo/role/role.repo.integration.spec.ts index 04834698419..65bc0931a6e 100644 --- a/apps/server/src/shared/repo/role/role.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/role/role.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError, NullCacheAdapter, ValidationError } from '@mikro-orm/cor import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Role, RoleName } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, roleFactory } from '@shared/testing'; import { RoleRepo } from './role.repo'; diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts index e9f14ba3315..775c193675d 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts @@ -13,7 +13,7 @@ import { SystemEntity, UserLoginMigrationEntity, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { legacySchoolDoFactory, schoolFactory, diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts index a2844e8e426..47196013b63 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { type SchoolEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; import { cleanupCollections, diff --git a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts index 707ff1b778d..1f24fe0bd0b 100644 --- a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts +++ b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { StorageProviderEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, storageProviderFactory } from '@shared/testing'; import { StorageProviderRepo } from './storageprovider.repo'; diff --git a/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts b/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts index 772c9d4ea1b..547fe2f05a1 100644 --- a/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Submission } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, courseFactory, diff --git a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts b/apps/server/src/shared/repo/system/system.repo.integration.spec.ts index 7c3c10a2fab..d8a5c1d87e3 100644 --- a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/system/system.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { SystemRepo } from '@shared/repo'; import { systemFactory } from '@shared/testing/factory/system.factory'; diff --git a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts index 52f20ab2cab..b975f485dea 100644 --- a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts @@ -1,7 +1,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SortOrder, Task } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, courseFactory, diff --git a/apps/server/src/shared/repo/teams/team.repo.integration.spec.ts b/apps/server/src/shared/repo/teams/team.repo.integration.spec.ts index 8d68ea8cf21..92e3d9d63c7 100644 --- a/apps/server/src/shared/repo/teams/team.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/teams/team.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, TeamEntity, TeamUserEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { TeamsRepo } from '@shared/repo'; import { cleanupCollections, roleFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; diff --git a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts index ddcfb55b520..43d46a95383 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts @@ -16,7 +16,7 @@ import { } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { cleanupCollections, diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index d469b59c14b..ea10e6e5b3e 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -2,7 +2,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { MatchCreator, SortOrder, SystemEntity, User } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, importUserFactory, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { systemFactory } from '@shared/testing/factory/system.factory'; import { UserRepo } from './user.repo'; diff --git a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts index 230e715307a..266cd0381c1 100644 --- a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts @@ -3,7 +3,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, schoolFactory, systemFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { userLoginMigrationFactory } from '../../testing/factory/user-login-migration.factory'; diff --git a/apps/server/src/shared/repo/videoconference/video-conference.repo.spec.ts b/apps/server/src/shared/repo/videoconference/video-conference.repo.spec.ts index 239b80fc3e9..06e4f057fa7 100644 --- a/apps/server/src/shared/repo/videoconference/video-conference.repo.spec.ts +++ b/apps/server/src/shared/repo/videoconference/video-conference.repo.spec.ts @@ -1,7 +1,7 @@ import { VideoConferenceRepo } from '@shared/repo'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; import { IVideoConferenceProperties, diff --git a/apps/server/src/shared/testing/factory/axios-error.factory.ts b/apps/server/src/shared/testing/factory/axios-error.factory.ts new file mode 100644 index 00000000000..089179dafef --- /dev/null +++ b/apps/server/src/shared/testing/factory/axios-error.factory.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { axiosResponseFactory } from '@shared/testing'; +import { AxiosError, AxiosHeaders } from 'axios'; +import { Factory } from 'fishery'; + +class AxiosErrorFactory extends Factory { + withError(error: unknown): this { + return this.params({ + response: axiosResponseFactory.build({ status: HttpStatus.BAD_REQUEST, data: error }), + }); + } +} + +export const axiosErrorFactory = AxiosErrorFactory.define(() => { + return { + status: HttpStatus.BAD_REQUEST, + config: { headers: new AxiosHeaders() }, + isAxiosError: true, + code: HttpStatus.BAD_REQUEST.toString(), + message: 'Bad Request', + name: 'BadRequest', + response: axiosResponseFactory.build({ status: HttpStatus.BAD_REQUEST }), + stack: 'mockStack', + toJSON: () => { + return { someJson: 'someJson' }; + }, + }; +}); diff --git a/apps/server/src/shared/testing/factory/filerecord.factory.ts b/apps/server/src/shared/testing/factory/filerecord.factory.ts index 36811ed9752..4e12787f661 100644 --- a/apps/server/src/shared/testing/factory/filerecord.factory.ts +++ b/apps/server/src/shared/testing/factory/filerecord.factory.ts @@ -1,4 +1,4 @@ -import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { FileRecord, FileRecordSecurityCheck, IFileRecordProperties } from '@modules/files-storage/entity'; import { ObjectId } from 'bson'; import { DeepPartial } from 'fishery'; 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..54fac672098 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'; @@ -37,3 +39,4 @@ export * from './user.do.factory'; export * from './user.factory'; export * from './legacy-file-entity-mock.factory'; export * from './jwt.test.factory'; +export * from './axios-error.factory'; diff --git a/backup/setup/context_external_tools.json b/backup/setup/context-external-tools.json similarity index 100% rename from backup/setup/context_external_tools.json rename to backup/setup/context-external-tools.json diff --git a/backup/setup/external_tools.json b/backup/setup/external-tools.json similarity index 100% rename from backup/setup/external_tools.json rename to backup/setup/external-tools.json diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 90fcee0baa3..e00ca430152 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -339,5 +339,16 @@ "$date": "2023-10-26T13:06:27.322Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "654cc2326b83f786c4227b21" + }, + "state": "up", + "name": "tool-and-user-login-migration-renamings", + "createdAt": { + "$date": "2023-11-09T11:27:46.062Z" + }, + "__v": 0 } ] diff --git a/backup/setup/school_external_tools.json b/backup/setup/school-external-tools.json similarity index 100% rename from backup/setup/school_external_tools.json rename to backup/setup/school-external-tools.json diff --git a/backup/setup/user-login-migrations.json b/backup/setup/user-login-migrations.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/backup/setup/user-login-migrations.json @@ -0,0 +1 @@ +[] diff --git a/config/default.schema.json b/config/default.schema.json index a34d8e899ad..ae43648f3ea 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -177,6 +177,7 @@ }, "ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS": { "type": "string", + "default":"", "description": "Add custom domain to the list of blocked domains (comma separated list)." }, "FEATURE_TSP_AUTO_CONSENT_ENABLED": { @@ -1271,11 +1272,6 @@ "default": false, "description": "Makes the new school administration page the default page" }, - "FEATURE_CLIENT_USER_LOGIN_MIGRATION_ENABLED": { - "type": "boolean", - "default": false, - "description": "Changes the schulcloud client to use new login endpoints" - }, "FEATURE_CTL_TOOLS_TAB_ENABLED": { "type": "boolean", "default": false, 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/jest.config.ts b/jest.config.ts index 032f4828dde..9ee33256a5e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -26,6 +26,7 @@ let config: Config.InitialOptions = { '^@shared/(.*)$': '/apps/server/src/shared/$1', '^@src/(.*)$': '/apps/server/src/$1', '^@modules/(.*)$': '/apps/server/src/modules/$1', + '^@infra/(.*)$': '/apps/server/src/infra/$1', }, maxWorkers: 2, // limited for not taking all workers within of a single github action }; diff --git a/migrations/1699529266062-tool-and-user-login-migration-renamings.js b/migrations/1699529266062-tool-and-user-login-migration-renamings.js new file mode 100644 index 00000000000..e1b0695b444 --- /dev/null +++ b/migrations/1699529266062-tool-and-user-login-migration-renamings.js @@ -0,0 +1,47 @@ +const mongoose = require('mongoose'); +const { info, error } = require('../src/logger'); +const { connect, close } = require('../src/utils/database'); + +async function aggregateAndDropCollection(oldName, newName) { + try { + const { connection } = mongoose; + + // Aggregation pipeline for copying the documents + const pipeline = [{ $match: {} }, { $out: newName }]; + + // Copy documents from the old collection to the new collection + await connection.collection(oldName).aggregate(pipeline).toArray(); + info(`Aggregated and copied documents from ${oldName} to ${newName}`); + + // Delete old collection + await connection.collection(oldName).drop(); + info(`Dropped collection ${oldName}`); + } catch (err) { + error(`Error aggregating, copying, and deleting collection ${oldName} to ${newName}: ${err.message}`); + throw err; + } +} + +module.exports = { + up: async function up() { + await connect(); + + await aggregateAndDropCollection('user_login_migrations', 'user-login-migrations'); + await aggregateAndDropCollection('external_tools', 'external-tools'); + await aggregateAndDropCollection('context_external_tools', 'context-external-tools'); + await aggregateAndDropCollection('school_external_tools', 'school-external-tools'); + + await close(); + }, + + down: async function down() { + await connect(); + + await aggregateAndDropCollection('user-login-migrations', 'user_login_migrations'); + await aggregateAndDropCollection('external-tools', 'external_tools'); + await aggregateAndDropCollection('context-external-tools', 'context_external_tools'); + await aggregateAndDropCollection('school-external-tools', 'school_external_tools'); + + await close(); + }, +}; diff --git a/package-lock.json b/package-lock.json index cd0606b9fde..bf63523c91a 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", @@ -176,7 +178,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-jest": "^27.1.6", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-no-only-tests": "^3.1.0", @@ -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" }, @@ -6865,21 +6866,33 @@ "node": ">=6.0" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -6913,15 +6926,73 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", - "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -7749,12 +7820,13 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9282,6 +9354,19 @@ "node": ">=0.8" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -9292,10 +9377,11 @@ } }, "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9707,35 +9793,49 @@ } }, "node_modules/es-abstract": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.5.tgz", - "integrity": "sha512-7h8MM2EQhsCA7pU/Nv78qOXFpD8Rhqd12gYiSJVkrH9+e8VuA8JlPJK/hQjjlLv6pJvx/z1iRFKzYb0XT/RuAQ==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dependencies": { - "call-bind": "^1.0.2", + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "unbox-primitive": "^1.0.2" + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9750,6 +9850,28 @@ "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", "dev": true }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, "node_modules/es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -10440,13 +10562,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "dependencies": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10532,16 +10655,20 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", "dev": true, "dependencies": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "engines": { "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/eslint-module-utils/node_modules/debug": { @@ -10554,24 +10681,28 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "engines": { "node": ">=4" @@ -10581,12 +10712,12 @@ } }, "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { @@ -10613,11 +10744,14 @@ "json5": "lib/cli.js" } }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } }, "node_modules/eslint-plugin-import/node_modules/strip-bom": { "version": "3.0.0", @@ -10629,13 +10763,13 @@ } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -11794,18 +11928,6 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dev": true, - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/fishery": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", @@ -12148,19 +12270,22 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -12232,13 +12357,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12354,6 +12480,20 @@ "node": ">=4" } }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -12514,6 +12654,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -12557,6 +12698,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", @@ -12614,6 +12766,17 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -12737,6 +12900,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", @@ -12959,12 +13149,12 @@ "dev": true }, "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" }, "engines": { @@ -13024,6 +13214,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -13101,11 +13304,11 @@ } }, "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13344,15 +13547,11 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -16055,19 +16254,6 @@ "node": ">=6.11.5" } }, - "node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dev": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -18604,9 +18790,9 @@ } }, "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18650,15 +18836,44 @@ "node": ">= 0.4" } }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, "node_modules/object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -18967,30 +19182,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -19003,15 +19194,6 @@ "node": ">=8" } }, - "node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -19179,15 +19361,6 @@ "node": ">= 0.4.0" } }, - "node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -20580,8 +20753,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", @@ -20589,13 +20761,13 @@ "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" }, "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -20831,11 +21003,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -21497,6 +21669,28 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -21806,6 +22000,33 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -22393,27 +22614,43 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -23776,6 +24013,67 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -24361,16 +24659,15 @@ "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" }, "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -26659,10 +26956,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" } @@ -29958,21 +30254,30 @@ "@babel/runtime-corejs3": "^7.10.2" } }, + "array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "requires": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" } }, @@ -29997,15 +30302,55 @@ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" }, + "array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + } + }, "array.prototype.flat": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", - "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" } }, "asap": { @@ -30676,12 +31021,13 @@ } }, "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" } }, "call-me-maybe": { @@ -31872,6 +32218,16 @@ } } }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -31879,10 +32235,11 @@ "dev": true }, "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } @@ -32217,35 +32574,49 @@ } }, "es-abstract": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.5.tgz", - "integrity": "sha512-7h8MM2EQhsCA7pU/Nv78qOXFpD8Rhqd12gYiSJVkrH9+e8VuA8JlPJK/hQjjlLv6pJvx/z1iRFKzYb0XT/RuAQ==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "requires": { - "call-bind": "^1.0.2", + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "unbox-primitive": "^1.0.2" + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" } }, "es-module-lexer": { @@ -32254,6 +32625,25 @@ "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", "dev": true }, + "es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "requires": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + } + }, + "es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, "es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -32816,13 +33206,14 @@ "requires": {} }, "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "requires": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" }, "dependencies": { "debug": { @@ -32882,13 +33273,12 @@ } }, "eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", "dev": true, "requires": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "dependencies": { "debug": { @@ -32903,33 +33293,37 @@ } }, "eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "requires": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "dependencies": { "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "doctrine": { @@ -32950,10 +33344,10 @@ "minimist": "^1.2.0" } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, "strip-bom": { @@ -32963,13 +33357,13 @@ "dev": true }, "tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, "requires": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -33695,15 +34089,6 @@ "pkg-dir": "^4.1.0" } }, - "find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dev": true, - "requires": { - "locate-path": "^2.0.0" - } - }, "fishery": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", @@ -33949,19 +34334,19 @@ } }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" } }, "functional-red-black-tree": { @@ -34012,13 +34397,14 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "get-package-type": { @@ -34095,6 +34481,14 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "requires": { + "define-properties": "^1.1.3" + } + }, "globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -34230,6 +34624,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -34258,6 +34653,11 @@ "get-intrinsic": "^1.1.1" } }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" + }, "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", @@ -34296,6 +34696,14 @@ } } }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -34395,6 +34803,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", @@ -34562,12 +34983,12 @@ } }, "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" } }, @@ -34606,6 +35027,16 @@ "has-tostringtag": "^1.0.0" } }, + "is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + } + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -34648,11 +35079,11 @@ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" }, "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "requires": { - "has": "^1.0.3" + "hasown": "^2.0.0" } }, "is-date-object": { @@ -34798,15 +35229,11 @@ } }, "is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" } }, "is-typedarray": { @@ -36896,16 +37323,6 @@ "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", "dev": true }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dev": true, - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -38929,9 +39346,9 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" }, "object-keys": { "version": "1.1.1", @@ -38960,15 +39377,38 @@ "es-abstract": "^1.19.1" } }, + "object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, "object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "on-headers": { @@ -39192,26 +39632,6 @@ "yocto-queue": "^0.1.0" } }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "requires": { - "p-limit": "^1.1.0" - }, - "dependencies": { - "p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "requires": { - "p-try": "^1.0.0" - } - } - } - }, "p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -39221,12 +39641,6 @@ "aggregate-error": "^3.0.0" } }, - "p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true - }, "package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -39349,12 +39763,6 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -40378,8 +40786,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", @@ -40387,13 +40794,13 @@ "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" }, "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" } }, "regexpp": { @@ -40565,11 +40972,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "requires": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } @@ -41064,6 +41471,24 @@ "tslib": "^2.1.0" } }, + "safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + } + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -41314,6 +41739,27 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -41800,24 +42246,34 @@ } } }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "strip-ansi": { @@ -42817,6 +43273,49 @@ } } }, + "typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -43287,16 +43786,15 @@ "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" }, "which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "requires": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" } }, "window-size": { diff --git a/package.json b/package.json index 45e150f6668..53a0752ee10 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", @@ -259,7 +261,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-jest": "^27.1.6", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-no-only-tests": "^3.1.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 06a54c6cf96..62615f0efb1 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -56,7 +56,6 @@ const exposedVars = [ 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED', 'MIGRATION_END_GRACE_PERIOD_MS', - 'FEATURE_CLIENT_USER_LOGIN_MIGRATION_ENABLED', 'FEATURE_CTL_TOOLS_TAB_ENABLED', 'FEATURE_LTI_TOOLS_TAB_ENABLED', 'FILES_STORAGE__MAX_FILE_SIZE', diff --git a/src/services/school/model.js b/src/services/school/model.js index 0ec931e4191..787f7b55348 100644 --- a/src/services/school/model.js +++ b/src/services/school/model.js @@ -177,7 +177,7 @@ const gradeLevelSchema = new Schema({ }); const schoolModel = mongoose.model('school', schoolSchema); -const userLoginMigrationModel = mongoose.model('userLoginMigration', userLoginMigrationSchema, 'user_login_migrations'); +const userLoginMigrationModel = mongoose.model('userLoginMigration', userLoginMigrationSchema, 'user-login-migrations'); const schoolGroupModel = mongoose.model('schoolGroup', schoolGroupSchema); const yearModel = mongoose.model('year', yearSchema); const gradeLevelModel = mongoose.model('gradeLevel', gradeLevelSchema); diff --git a/tsconfig.json b/tsconfig.json index 9bb7c6f72f0..147df972b6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "@shared/*": ["apps/server/src/shared/*"], "@src/*": ["apps/server/src/*"], "@modules/*": ["apps/server/src/modules/*"], + "@infra/*": ["apps/server/src/infra/*"], }, } }