diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 5e4bf5a6a98..94b3514822f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -6,7 +6,7 @@ on: branches-ignore: - dependabot/** pull_request: - types: [ labeled ] + types: [labeled] permissions: contents: read @@ -32,14 +32,6 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Docker meta Service Name - id: docker_meta_img - uses: crazy-max/ghaction-docker-meta@v4 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=ref,event=branch,enable=false,priority=600 - type=sha,enable=true,priority=600,prefix= - name: Login to registry uses: docker/login-action@v2 @@ -48,10 +40,17 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta Service Name + id: docker_meta_img + uses: docker/metadata-action@v4 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch,enable=false,priority=600 + type=sha,enable=true,priority=600,prefix= + - name: test image exists run: | - mkdir -p ~/.docker - echo '{"experimental": "enabled"}' >> ~/.docker/config.json echo "IMAGE_EXISTS=$(docker manifest inspect ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }} > /dev/null && echo 1 || echo 0)" >> $GITHUB_ENV - name: Set up Docker Buildx @@ -69,6 +68,49 @@ jobs: tags: ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }} labels: ${{ steps.docker_meta_img.outputs.labels }} + - name: Docker meta Service Name (file storage) + id: docker_meta_img_file_storage + uses: docker/metadata-action@v4 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch,enable=false,priority=600,prefix=file-storage- + type=sha,enable=true,priority=600,prefix=file-storage- + labels: | + org.opencontainers.image.title=schulcloud-file-storage + + - name: test image exists (file storage) + run: | + echo "IMAGE_EXISTS=$(docker manifest inspect ghcr.io/${{ github.repository }}:file-storage-${{ needs.branch_meta.outputs.sha }} > /dev/null && echo 1 || echo 0)" >> $GITHUB_ENV + + - name: Set up Docker Buildx (file storage) + if: ${{ env.IMAGE_EXISTS == 0 }} + uses: docker/setup-buildx-action@v2 + + - name: Build and push ${{ github.repository }} (file storage) + if: ${{ env.IMAGE_EXISTS == 0 }} + uses: docker/build-push-action@v4 + with: + build-args: | + BASE_IMAGE=ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }} + context: . + file: ./Dockerfile.filestorage + platforms: linux/amd64 + push: true + tags: ghcr.io/${{ github.repository }}:file-storage-${{ needs.branch_meta.outputs.sha }} + labels: | + ${{ steps.docker_meta_img_file_storage.outputs.labels }} + + - name: Send Notification to Rocket Chat if docker image build failed + if: ${{ failure() && github.ref == 'refs/heads/main' }} + uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@1.1.1 + with: + type: ${{ job.status }} + job_name: 'docker image build from ${{ github.repository }} triggered from branch ${{ github.ref_name }}:' + url: ${{ secrets.RC_MAIN_BROKEN_TOKEN }} + channel: '#softwaredevelopment-teams-and-groups' + username: Autodeployment Info + branch_meta: runs-on: ubuntu-latest outputs: @@ -87,7 +129,6 @@ jobs: echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT fi - deploy: needs: - build_and_push @@ -147,12 +188,12 @@ jobs: uses: hpi-schul-cloud/e2e-system-tests/.github/workflows/remote-trigger.yml@main with: ref: ${{ needs.branch_meta.outputs.branch }} - secrets: inherit - + secrets: + service-account-token: ${{ secrets.CYPRESS_ONEPWD_SERVICE_ACCOUNT_TOKEN }} + test-successful: runs-on: ubuntu-latest needs: - end-to-end-tests steps: - run: echo "Test was successful" - diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index b09e03279b3..fa62df4b0ab 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -46,7 +46,29 @@ jobs: platforms: linux/amd64 push: true tags: ${{ steps.docker_meta_img_hub.outputs.tags }} - labels: ${{ steps.docker_meta_img.outputs.labels }} + labels: ${{ steps.docker_meta_img_hub.outputs.labels }} + + - name: Docker meta Service Name for docker hub (file storage) + id: docker_meta_img_hub_file_storage + uses: docker/metadata-action@v4 + with: + images: docker.io/schulcloud/schulcloud-server, quay.io/schulcloudverbund/schulcloud-server + tags: | + type=semver,pattern={{version}},prefix=file-storage-,onlatest=false + type=semver,pattern={{major}}.{{minor}},prefix=file-storage-,onlatest=false + labels: | + org.opencontainers.image.title=schulcloud-file-storage + - name: Build and push ${{ github.repository }} (file-storage) + uses: docker/build-push-action@v4 + with: + build-args: | + BASE_IMAGE=quay.io/schulcloudverbund/schulcloud-server:${{ github.ref_name }} + context: . + file: ./Dockerfile.filestorage + platforms: linux/amd64 + push: true + tags: ${{ steps.docker_meta_img_hub_file_storage.outputs.tags }} + labels: ${{ steps.docker_meta_img_hub_file_storage.outputs.labels }} create-release: needs: diff --git a/Dockerfile b/Dockerfile index 563a653b7db..8663c9b8d85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/node:18 as git +FROM docker.io/node:18 AS git RUN mkdir /app && chown -R node:node /app WORKDIR /app diff --git a/Dockerfile.filestorage b/Dockerfile.filestorage new file mode 100644 index 00000000000..7284f8c3885 --- /dev/null +++ b/Dockerfile.filestorage @@ -0,0 +1,4 @@ +ARG BASE_IMAGE +FROM $BASE_IMAGE + +RUN apk add --no-cache imagemagick \ No newline at end of file diff --git a/ansible/roles/h5p-library-management/meta/main.yml b/ansible/roles/h5p-library-management/meta/main.yml new file mode 100644 index 00000000000..2af8ef00aea --- /dev/null +++ b/ansible/roles/h5p-library-management/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + role_name: h5p-library-management + author: Schul-Cloud Verbund + description: h5p library role for the management of libraries + company: Schul-Cloud Verbund + license: license (AGPLv3) + min_ansible_version: 2.8 + galaxy_tags: [] +dependencies: [] diff --git a/ansible/roles/h5p-library-management/tasks/main.yml b/ansible/roles/h5p-library-management/tasks/main.yml new file mode 100644 index 00000000000..7d25364c934 --- /dev/null +++ b/ansible/roles/h5p-library-management/tasks/main.yml @@ -0,0 +1,6 @@ + - name: H5pLibraryManagement CronJob + when: WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-library-management-cronjob.yml.j2 diff --git a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 new file mode 100644 index 00000000000..89f9621be5a --- /dev/null +++ b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 @@ -0,0 +1,35 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: api-library-management-cronjob + name: api-library-management-cronjob +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/h5p-library-manager-ionos-s3" +spec: + schedule: "{{ SERVER_H5P_LIBRARY_MANAGEMENT_CRONJOB|default("0 3 * * 3,6", true) }}" + concurrencyPolicy: Forbid + jobTemplate: + spec: + activeDeadlineSeconds: {{ SERVER_H5P_LIBRARY_MANAGEMENT_CRONJOB_TIMEOUT|default("39600", true) }} + template: + spec: + containers: + - name: api-h5p-library-management-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:h5p:library-management'] + resources: + limits: + cpu: {{ API_H5P_LIBRARY_MANAGEMENT_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_H5P_LIBRARY_MANAGEMENT_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_H5P_LIBRARY_MANAGEMENT_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_H5P_LIBRARY_MANAGEMENT_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure diff --git a/ansible/roles/library-cron-job/api-library-management-cronjob.yml.j2 b/ansible/roles/library-cron-job/api-library-management-cronjob.yml.j2 deleted file mode 100644 index 1c2a0ae61bc..00000000000 --- a/ansible/roles/library-cron-job/api-library-management-cronjob.yml.j2 +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - namespace: {{ NAMESPACE }} - labels: - app: api-library-management-cronjob - name: api-library-management-cronjob -spec: - schedule: "{{ SERVER_LIBRARY_MANAGEMENT_CRONJOB|default("0 3 * * 3,6", true) }}" - concurrencyPolicy: Forbid - jobTemplate: - spec: - activeDeadlineSeconds: {{ SERVER_LIBRARY_MANAGEMENT_CRONJOB_TIMEOUT|default("39600", true) }} - template: - spec: - containers: - - name: api-library-management-cronjob - image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} - envFrom: - - configMapRef: - name: api-configmap - - secretRef: - name: api-secret - command: ['/schulcloud-server/scripts/ldapSync.sh'] - resources: - limits: - cpu: {{ API_CPU_LIMITS|default("2000m", true) }} - memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} - requests: - cpu: {{ API_CPU_REQUESTS|default("100m", true) }} - memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} - restartPolicy: OnFailure diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 5fddc766ac9..fff5b10197a 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -71,13 +71,6 @@ namespace: "{{ NAMESPACE }}" template: api-fwu-deployment.yml.j2 when: FEATURE_FWU_CONTENT_ENABLED is defined and FEATURE_FWU_CONTENT_ENABLED|bool - - - name: H5pEditorDeployment - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: api-h5p-deployment.yml.j2 - when: FEATURE_H5P_EDITOR_ENABLED is defined and FEATURE_H5P_EDITOR_ENABLED|bool - name: Delete Files CronJob kubernetes.core.k8s: diff --git a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 index 727b5a9f4f0..7f5a5b7e50b 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 @@ -29,7 +29,7 @@ spec: runAsNonRoot: true containers: - name: api-files - image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + image: {{ SCHULCLOUD_SERVER_IMAGE }}:file-storage-{{ SCHULCLOUD_SERVER_IMAGE_TAG }} imagePullPolicy: IfNotPresent ports: - containerPort: 4444 diff --git a/ansible/roles/schulcloud-server-core/templates/configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/configmap.yml.j2 index 08ef9a8ff7f..537c3b15ee3 100644 --- a/ansible/roles/schulcloud-server-core/templates/configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/configmap.yml.j2 @@ -11,7 +11,6 @@ data: NODE_ENV: "production" NODE_OPTIONS: "--max-old-space-size=2048" SC_TITLE: "{{ SC_TITLE }}" - SC_SHORT_TITLE: "{{ SC_SHORT_TITLE }}" SC_THEME: "{{ SC_THEME }}" SC_DOMAIN: "{{ DOMAIN }}" SHLVL: "1" diff --git a/ansible/roles/schulcloud-server-h5p/meta/main.yml b/ansible/roles/schulcloud-server-h5p/meta/main.yml new file mode 100644 index 00000000000..33a6e09b3b1 --- /dev/null +++ b/ansible/roles/schulcloud-server-h5p/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + role_name: schulcloud-server-h5p + author: Schul-Cloud Verbund + description: h5p role for the schulcloud-server + company: Schul-Cloud Verbund + license: license (AGPLv3) + min_ansible_version: 2.8 + galaxy_tags: [] +dependencies: [] diff --git a/ansible/roles/schulcloud-server-h5p/tasks/main.yml b/ansible/roles/schulcloud-server-h5p/tasks/main.yml new file mode 100644 index 00000000000..f630b1f3671 --- /dev/null +++ b/ansible/roles/schulcloud-server-h5p/tasks/main.yml @@ -0,0 +1,14 @@ + - name: H5pEditorService + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-svc.yml.j2 + when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + + - name: H5pEditorDeployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-deployment.yml.j2 + when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-core/templates/api-h5p-deployment.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 similarity index 100% rename from ansible/roles/schulcloud-server-core/templates/api-h5p-deployment.yml.j2 rename to ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 diff --git a/ansible/roles/schulcloud-server-core/templates/api-h5p-svc.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-svc.yml.j2 similarity index 87% rename from ansible/roles/schulcloud-server-core/templates/api-h5p-svc.yml.j2 rename to ansible/roles/schulcloud-server-h5p/templates/api-h5p-svc.yml.j2 index 2c9b9909d0e..b90a0701a5d 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-h5p-svc.yml.j2 +++ b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-svc.yml.j2 @@ -8,7 +8,7 @@ metadata: spec: type: ClusterIP ports: - - port: {{ PORT_H5P_EDITOR }} + - port: 4448 targetPort: 4448 protocol: TCP name: api-h5p 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 8066854ad63..6b17e522753 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 @@ -253,6 +253,49 @@ data: # ========== End of the Dev IServ configuration section. + # ========== Start of the Test BRB Univention LDAP system (also used on the REF BRB) configuration section. + + # This is currently performed for any 'brb-*' namespace ('brb-main' for example). + + if [[ "$NS" =~ ^brb-[^\s]+$ ]]; then + UNIVENTION_LDAP_SYSTEM_ID=621beef78ec63ea12a3adae6 + UNIVENTION_LDAP_FEDERAL_STATE_ID=0000b186816abba584714c53 + + # Encrypt LDAP server's search user password. + UNIVENTION_LDAP_SEARCH_USER_PASSWORD=$(node scripts/secret.js -s $AES_KEY -e $UNIVENTION_LDAP_SEARCH_USER_PASSWORD) + + # Add (or replace) document with the test BRB Univention LDAP system configuration. + mongosh $DATABASE__URL --eval 'db.systems.replaceOne( + { + "_id": ObjectId("'$UNIVENTION_LDAP_SYSTEM_ID'"), + }, + { + "_id": ObjectId("'$UNIVENTION_LDAP_SYSTEM_ID'"), + "alias": "TEST BRB UNIVENTION LDAP", + "ldapConfig": { + "active": true, + "federalState": ObjectId("'$UNIVENTION_LDAP_FEDERAL_STATE_ID'"), + "url": "'$UNIVENTION_LDAP_URL'", + "rootPath": "'$UNIVENTION_LDAP_ROOT_PATH'", + "searchUser": "'$UNIVENTION_LDAP_SEARCH_USER'", + "searchUserPassword": "'$UNIVENTION_LDAP_SEARCH_USER_PASSWORD'", + "provider": "univention", + "providerOptions": { + "userAttributeNameMapping": {}, + "roleAttributeNameMapping": {}, + "classAttributeNameMapping": {} + } + }, + "type": "ldap" + }, + { + "upsert": true + } + );' + fi + + # ========== End of the Test BRB Univention LDAP system (also used on the REF BRB) configuration section. + # ========== Start of the Bettermarks tool configuration section. # This is currently performed only for the following 4 namespaces: diff --git a/apps/server/doc/summary.json b/apps/server/doc/summary.json index 3ef4d9cf96f..834b92cb5f4 100644 --- a/apps/server/doc/summary.json +++ b/apps/server/doc/summary.json @@ -58,6 +58,10 @@ { "title": "Code Style", "file": "code-style.md" + }, + { + "title": "S3ClientModule", + "file": "../src/shared/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 0b249ecc192..6792c644ce9 100644 --- a/apps/server/src/apps/h5p-editor.app.ts +++ b/apps/server/src/apps/h5p-editor.app.ts @@ -7,8 +7,6 @@ import express from 'express'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; -import path from 'node:path'; - // application imports import { LegacyLogger } from '@src/core/logger'; import { H5PEditorModule } from '@src/modules/h5p-editor'; @@ -22,13 +20,6 @@ async function bootstrap() { const nestExpressAdapter = new ExpressAdapter(nestExpress); - const oneHourInMs = 1000 * 60 * 60; - - nestExpressAdapter.useStaticAssets(path.join(__dirname, '../static-assets/h5p'), { - prefix: '/h5p-editor', - maxAge: oneHourInMs, - }); - const nestApp = await NestFactory.create(H5PEditorModule, nestExpressAdapter); // WinstonLogger nestApp.useLogger(await nestApp.resolve(LegacyLogger)); diff --git a/apps/server/src/apps/h5p-library-management.app.ts b/apps/server/src/apps/h5p-library-management.app.ts index 0de9d622672..5dc31784cab 100644 --- a/apps/server/src/apps/h5p-library-management.app.ts +++ b/apps/server/src/apps/h5p-library-management.app.ts @@ -13,14 +13,23 @@ import { H5PLibraryManagementService } from '@src/modules/h5p-library-management async function bootstrap() { sourceMapInstall(); - const app = await NestFactory.createApplicationContext(H5PLibraryManagementModule); + const nestApp = await NestFactory.createApplicationContext(H5PLibraryManagementModule); // WinstonLogger - app.useLogger(await app.resolve(LegacyLogger)); + nestApp.useLogger(await nestApp.resolve(LegacyLogger)); - await app.get(H5PLibraryManagementService).run(); + await nestApp.init(); + + console.log('#########################################'); + console.log(`##### Start H5P Library Management ######`); + console.log('#########################################'); + + await nestApp.get(H5PLibraryManagementService).run(); + // await app.get(H5PLibraryManagementService).run(); // TODO: properly close app (there is some issue with the logger) - // await app.close(); - process.exit(0); + console.log('#########################################'); + console.log(`##### Close H5P Library Management ######`); + console.log('#########################################'); + await nestApp.close(); } void bootstrap(); diff --git a/apps/server/src/apps/helpers/prometheus-metrics.spec.ts b/apps/server/src/apps/helpers/prometheus-metrics.spec.ts index 6fd68204e5c..0c4530f99b8 100644 --- a/apps/server/src/apps/helpers/prometheus-metrics.spec.ts +++ b/apps/server/src/apps/helpers/prometheus-metrics.spec.ts @@ -1,14 +1,13 @@ import { createMock } from '@golevelup/ts-jest'; - -import express, { Request, Response, NextFunction, RequestHandler, Express } from 'express'; -import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { Configuration } from '@hpi-schul-cloud/commons'; -import { Logger } from '@src/core/logger'; +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { PrometheusMetricsConfig, createAPIResponseTimeMetricMiddleware, createPrometheusMetricsApp, } from '@shared/infra/metrics'; +import { Logger } from '@src/core/logger'; +import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; import { PrometheusMetricsSetupState, PrometheusMetricsSetupStateLoggable, @@ -111,7 +110,7 @@ describe('addPrometheusMetricsMiddlewaresIfEnabled', () => { describe('createAndStartPrometheusMetricsAppIfEnabled', () => { describe('should create Prometheus metrics app and run it', () => { const testPort = 9000; - const testLoggerSpy = jest.spyOn(testLogger, 'log'); + const testLoggerSpy = jest.spyOn(testLogger, 'info'); let appMockListenFn: jest.Mock; diff --git a/apps/server/src/apps/helpers/prometheus-metrics.ts b/apps/server/src/apps/helpers/prometheus-metrics.ts index 53ec4425bd0..751cada4c2f 100644 --- a/apps/server/src/apps/helpers/prometheus-metrics.ts +++ b/apps/server/src/apps/helpers/prometheus-metrics.ts @@ -1,11 +1,11 @@ import { Express } from 'express'; -import { LogMessage, Loggable, Logger } from '@src/core/logger'; import { PrometheusMetricsConfig, createAPIResponseTimeMetricMiddleware, createPrometheusMetricsApp, } from '@shared/infra/metrics'; +import { LogMessage, Loggable, Logger } from '@src/core/logger'; import { AppStartLoggable } from './app-start-loggable'; export const enum PrometheusMetricsSetupState { @@ -77,7 +77,7 @@ export const createAndStartPrometheusMetricsAppIfEnabled = (logger: Logger) => { const prometheusMetricsApp = createPrometheusMetricsApp(route, collectDefaultMetrics, collectMetricsRouteMetrics); prometheusMetricsApp.listen(prometheusMetricsAppPort, () => { - logger.log( + logger.info( new AppStartLoggable({ appName: 'Prometheus metrics server app', port: prometheusMetricsAppPort, diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index f5e48a1c1c6..81a35f6bfa6 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -8,6 +8,7 @@ import { enableOpenApiDocs } from '@shared/controller/swagger'; import { Mail, MailService } from '@shared/infra/mail'; import { LegacyLogger, Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; +import { TeamService } from '@src/modules/teams/service/team.service'; import { AccountValidationService } from '@src/modules/account/services/account.validation.service'; import { AccountUc } from '@src/modules/account/uc/account.uc'; import { CollaborativeStorageUc } from '@src/modules/collaborative-storage/uc/collaborative-storage.uc'; @@ -20,11 +21,11 @@ import { join } from 'path'; import { install as sourceMapInstall } from 'source-map-support'; import legacyAppPromise = require('../../../../src/app'); +import { AppStartLoggable } from './helpers/app-start-loggable'; import { addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, } from './helpers/prometheus-metrics'; -import { AppStartLoggable } from './helpers/app-start-loggable'; async function bootstrap() { sourceMapInstall(); @@ -61,9 +62,9 @@ async function bootstrap() { // provide NestJS mail service to feathers app // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-mail'] = { - send(data: Mail): void { + async send(data: Mail): Promise { const mailService = nestApp.get(MailService); - mailService.send(data); + await mailService.send(data); }, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment @@ -76,6 +77,8 @@ async function bootstrap() { feathersExpress.services['nest-account-uc'] = nestApp.get(AccountUc); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-collaborative-storage-uc'] = nestApp.get(CollaborativeStorageUc); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + feathersExpress.services['nest-team-service'] = nestApp.get(TeamService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; @@ -107,7 +110,7 @@ async function bootstrap() { const port = 3030; rootExpress.listen(port, () => { - logger.log( + logger.info( new AppStartLoggable({ appName: 'Main server app', port, 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 985cd9d518a..6a2a8de7e7f 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,29 +1,34 @@ import { INestApplicationContext } from '@nestjs/common'; -import { BootstrapConsole, ConsoleService } from 'nestjs-console'; +import { ConsoleWriterService } from '@shared/infra/console'; import { ServerConsoleModule } from '@src/console/console.module'; import { CommanderError } from 'commander'; -import { execute, TestBootstrapConsole } from './test-bootstrap.console'; +import { BootstrapConsole, ConsoleService } from 'nestjs-console'; +import { TestBootstrapConsole, execute } from './test-bootstrap.console'; describe('DatabaseManagementConsole (API)', () => { let app: INestApplicationContext; - let console: BootstrapConsole; + let bootstrap: BootstrapConsole; let consoleService: ConsoleService; - beforeAll(async () => { - console = new TestBootstrapConsole({ + let consoleWriter: ConsoleWriterService; + + beforeEach(async () => { + bootstrap = new TestBootstrapConsole({ module: ServerConsoleModule, useDecorators: true, }); - app = await console.init(); + app = await bootstrap.init(); await app.init(); consoleService = app.get(ConsoleService); + consoleWriter = app.get(ConsoleWriterService); }); - afterAll(async () => { + afterEach(async () => { + consoleService.resetCli(); await app.close(); }); describe('Command "database"', () => { - beforeEach(() => { + const setup = () => { const cli = consoleService.getCli('database'); const exitFn = (err: CommanderError) => { if (err.exitCode !== 0) throw err; @@ -31,22 +36,44 @@ describe('DatabaseManagementConsole (API)', () => { cli?.exitOverride(exitFn); const rootCli = consoleService.getRootCli(); rootCli.exitOverride(exitFn); - }); + const spyConsoleWriterInfo = jest.spyOn(consoleWriter, 'info'); + return { spyConsoleWriterInfo }; + }; + describe('when command not exists', () => { + it('should fail for unknown command', async () => { + setup(); + await expect(execute(bootstrap, ['database', 'not_existing_command'])).rejects.toThrow( + `error: unknown command 'not_existing_command'` + ); - afterEach(() => { - consoleService.resetCli(); + consoleService.resetCli(); + }); }); - it('should fail for unknown command', async () => { - await expect(execute(console, ['database', 'not_existing_command'])).rejects.toThrow( - `error: unknown command 'not_existing_command'` - ); - }); - it('should provide command "seed"', async () => { - await execute(console, ['database', 'seed']); - }); - it('should provide command "export"', async () => { - await execute(console, ['database', 'export']); + describe('when command exists', () => { + it('should provide command "seed"', async () => { + const { spyConsoleWriterInfo } = setup(); + + await execute(bootstrap, ['database', 'seed']); + + expect(spyConsoleWriterInfo).toBeCalled(); + }); + + it('should provide command "export"', async () => { + const { spyConsoleWriterInfo } = setup(); + + await execute(bootstrap, ['database', 'export']); + + expect(spyConsoleWriterInfo).toBeCalled(); + }); + + it('should provide command "sync-indexes"', async () => { + const { spyConsoleWriterInfo } = setup(); + + await execute(bootstrap, ['database', 'sync-indexes']); + + expect(spyConsoleWriterInfo).toBeCalled(); + }); }); }); }); 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 02511d7abf4..282d448b05d 100644 --- a/apps/server/src/console/api-test/test-bootstrap.console.ts +++ b/apps/server/src/console/api-test/test-bootstrap.console.ts @@ -1,11 +1,19 @@ +import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@shared/infra/console'; +import { DatabaseManagementUc } from '@src/modules/management/uc/database-management.uc'; import { AbstractBootstrapConsole, BootstrapConsole } from 'nestjs-console'; export class TestBootstrapConsole extends AbstractBootstrapConsole { create(): Promise { return Test.createTestingModule({ imports: [this.options.module], - }).compile(); + }) + .overrideProvider(DatabaseManagementUc) + .useValue(createMock()) + .overrideProvider(ConsoleWriterService) + .useValue(createMock()) + .compile(); } } diff --git a/apps/server/src/console/console.module.ts b/apps/server/src/console/console.module.ts index cf1c6615028..547f3f4308b 100644 --- a/apps/server/src/console/console.module.ts +++ b/apps/server/src/console/console.module.ts @@ -9,7 +9,6 @@ import { KeycloakModule } from '@shared/infra/identity-management/keycloak/keycl import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { FilesModule } from '@src/modules/files'; import { FileRecord } from '@src/modules/files-storage/entity'; -import { H5PEditorModule } from '@src/modules/h5p-editor'; import { ManagementModule } from '@src/modules/management/management.module'; import { serverConfig } from '@src/modules/server'; import { ConsoleModule } from 'nestjs-console'; @@ -21,7 +20,6 @@ import { ServerConsole } from './server.console'; ConsoleModule, ConsoleWriterModule, FilesModule, - H5PEditorModule, ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), ...((Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean) ? [KeycloakModule] : []), MikroOrmModule.forRoot({ 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 15a661353c4..7e0d1dc3c3f 100644 --- a/apps/server/src/core/error/filter/global-error.filter.ts +++ b/apps/server/src/core/error/filter/global-error.filter.ts @@ -97,9 +97,9 @@ export class GlobalErrorFilter implements Exceptio return new ErrorResponse(type, title, msg, code); } - private createErrorResponseForUnknownError(error?: unknown): ErrorResponse { - const unknownError = new InternalServerErrorException(error); - const response = this.createErrorResponseForNestHttpException(unknownError); + private createErrorResponseForUnknownError(): ErrorResponse { + const error = new InternalServerErrorException(); + const response = this.createErrorResponseForNestHttpException(error); return response; } diff --git a/apps/server/src/core/error/loggable/error.loggable.spec.ts b/apps/server/src/core/error/loggable/error.loggable.spec.ts index 277395fd41c..79527f9a687 100644 --- a/apps/server/src/core/error/loggable/error.loggable.spec.ts +++ b/apps/server/src/core/error/loggable/error.loggable.spec.ts @@ -1,6 +1,8 @@ import { NotFound } from '@feathersjs/errors'; -import { BadRequestException, HttpStatus } from '@nestjs/common'; +import { BadRequestException, HttpStatus, ValidationError } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; import { ApiValidationError, BusinessError } from '@shared/common'; +import { PrivacyProtect } from '@shared/controller'; import { ErrorLoggable } from './error.loggable'; class SampleBusinessError extends BusinessError { @@ -16,21 +18,49 @@ class SampleBusinessError extends BusinessError { } } +class UserDto { + @ApiProperty() + username!: string; + + @ApiProperty() + email!: string; + + @PrivacyProtect() + @ApiProperty() + password!: string; +} + describe('ErrorLoggable', () => { describe('getLogMessage', () => { describe('when error is an ApiValidationError', () => { const setup = () => { - const validationError1 = { property: 'foo', value: 'bar', constraints: { foo: 'must be baz' } }; - const validationError2 = { property: 'bla', value: 'bli', constraints: { bla: 'must be blub' } }; - const error = new ApiValidationError([validationError1, validationError2]); + const validationError1: ValidationError = { + property: 'username', + target: new UserDto(), + value: '', + constraints: { username: 'must not be empty' }, + }; + const validationError2 = { + property: 'email', + // missing target + value: 'john-example.com', + constraints: { email: 'must be a valid email address' }, + }; + const validationError3 = { + property: 'password', // privacy protected property + target: new UserDto(), + value: 'john-example.com', + constraints: { password: 'must contain at least one number' }, + }; + const error = new ApiValidationError([validationError1, validationError2, validationError3]); const errorLoggable = new ErrorLoggable(error); const expectedMessage = { validationErrors: [ - 'Wrong property foo got bar : {"foo":"must be baz"}', - 'Wrong property bla got bli : {"bla":"must be blub"}', + 'Wrong property value for \'username\' got \'\' : {"username":"must not be empty"}', + 'Wrong property value for \'email\' got \'######\' : {"email":"must be a valid email address"}', + 'Wrong property value for \'password\' got \'######\' : {"password":"must contain at least one number"}', ], type: 'API Validation Error', - stack: error.stack, }; return { errorLoggable, expectedMessage }; @@ -52,7 +82,6 @@ describe('ErrorLoggable', () => { const expectedMessage = { error, type: 'Feathers Error', - stack: error.stack, }; return { errorLoggable, expectedMessage }; @@ -74,7 +103,6 @@ describe('ErrorLoggable', () => { const expectedMessage = { error, type: 'Business Error', - stack: error.stack, }; return { errorLoggable, expectedMessage }; @@ -96,7 +124,6 @@ describe('ErrorLoggable', () => { const expectedMessage = { error, type: 'Technical Error', - stack: error.stack, }; return { errorLoggable, expectedMessage }; @@ -118,7 +145,6 @@ describe('ErrorLoggable', () => { const expectedMessage = { error, type: 'Unhandled or Unknown Error', - stack: error.stack, }; return { errorLoggable, expectedMessage }; diff --git a/apps/server/src/core/error/loggable/error.loggable.ts b/apps/server/src/core/error/loggable/error.loggable.ts index c4e3e7345d9..518c738e8f9 100644 --- a/apps/server/src/core/error/loggable/error.loggable.ts +++ b/apps/server/src/core/error/loggable/error.loggable.ts @@ -1,4 +1,6 @@ import { ApiValidationError } from '@shared/common'; +import { getMetadataStorage } from 'class-validator'; +import { ValidationError } from '@nestjs/common'; import { Loggable } from '../../logger/interfaces'; import { ErrorLogMessage, ValidationErrorLogMessage } from '../../logger/types'; import { ErrorUtils } from '../utils/error.utils'; @@ -6,10 +8,11 @@ import { ErrorUtils } from '../utils/error.utils'; export class ErrorLoggable implements Loggable { constructor(private readonly error: Error) {} + private readonly classValidatorMetadataStorage = getMetadataStorage(); + getLogMessage(): ErrorLogMessage | ValidationErrorLogMessage { let logMessage: ErrorLogMessage | ValidationErrorLogMessage = { error: this.error, - stack: this.error.stack, type: '', }; @@ -29,14 +32,41 @@ export class ErrorLoggable implements Loggable { } private createLogMessageForValidationErrors(error: ApiValidationError) { - const errorMessages = error.validationErrors.map( + const errorMessages = error.validationErrors.map((e) => { + const value = this.getPropertyValue(e); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - (e) => `Wrong property ${e.property} got ${e.value} : ${JSON.stringify(e.constraints)}` - ); + const message = `Wrong property value for '${e.property}' got '${value}' : ${JSON.stringify(e.constraints)}`; + return message; + }); return { validationErrors: errorMessages, - stack: error.stack, type: 'API Validation Error', }; } + + private getPropertyValue(e: ValidationError): unknown { + // we can only log a value if we can decide if it is privacy protected + // that has to be done using the target metadata of class-validator (see @PrivacyProtect decorator) + if (e.target && !this.isPropertyPrivacyProtected(e.target, e.property)) { + return e.value; + } + return '######'; + } + + private isPropertyPrivacyProtected(target: Record, property: string): boolean { + const metadatas = this.classValidatorMetadataStorage.getTargetValidationMetadatas( + target.constructor, + '', + true, + true + ); + + const privacyProtected = metadatas.some( + (validationMetadata) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + validationMetadata.propertyName === property && validationMetadata.context?.privacyProtected + ); + + return privacyProtected; + } } diff --git a/apps/server/src/core/error/utils/error.utils.spec.ts b/apps/server/src/core/error/utils/error.utils.spec.ts index e3ccbf084f2..cd9f9061fc4 100644 --- a/apps/server/src/core/error/utils/error.utils.spec.ts +++ b/apps/server/src/core/error/utils/error.utils.spec.ts @@ -70,4 +70,46 @@ describe('ErrorUtils', () => { expect(result).toBe(false); }); }); + + describe('createHttpExceptionOptions', () => { + it('should return HttpExceptionOptions if error is instance of Error', () => { + const error = new BadRequestException(); + + const result = ErrorUtils.createHttpExceptionOptions(error); + + const expectedResult = { cause: error }; + + expect(result).toEqual(expectedResult); + }); + + it('should return HttpExceptionOptions if error is a string', () => { + const error = 'test string'; + + const result = ErrorUtils.createHttpExceptionOptions(error); + + const expectedResult = { cause: new Error(JSON.stringify(error)) }; + + expect(result).toEqual(expectedResult); + }); + + it('should return HttpExceptionOptions if error is a number', () => { + const error = 1; + + const result = ErrorUtils.createHttpExceptionOptions(error); + + const expectedResult = { cause: new Error(JSON.stringify(error)) }; + + expect(result).toEqual(expectedResult); + }); + + it('should return HttpExceptionOptions if error is a object', () => { + const error = { a: 1 }; + + const result = ErrorUtils.createHttpExceptionOptions(error); + + const expectedResult = { cause: new Error(JSON.stringify(error)) }; + + expect(result).toEqual(expectedResult); + }); + }); }); diff --git a/apps/server/src/core/error/utils/error.utils.ts b/apps/server/src/core/error/utils/error.utils.ts index e08592bf0f6..c578fbc9c72 100644 --- a/apps/server/src/core/error/utils/error.utils.ts +++ b/apps/server/src/core/error/utils/error.utils.ts @@ -1,4 +1,4 @@ -import { HttpException } from '@nestjs/common'; +import { HttpException, HttpExceptionOptions } from '@nestjs/common'; import { BusinessError } from '@shared/common'; import { FeathersError } from '../interface'; @@ -20,4 +20,16 @@ export class ErrorUtils { static isNestHttpException(error: unknown): error is HttpException { return error instanceof HttpException; } + + static createHttpExceptionOptions(error: unknown, description?: string): HttpExceptionOptions { + let causeError: Error | undefined; + + if (error instanceof Error) { + causeError = error; + } else { + causeError = error ? new Error(JSON.stringify(error)) : undefined; + } + + return { cause: causeError, description }; + } } diff --git a/apps/server/src/core/logger/error-logger.spec.ts b/apps/server/src/core/logger/error-logger.spec.ts index e8008e1ea64..fcca1f048c9 100644 --- a/apps/server/src/core/logger/error-logger.spec.ts +++ b/apps/server/src/core/logger/error-logger.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { WinstonLogger, WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger as WinstonLogger } from 'winston'; import { ErrorLoggable } from '../error/loggable/error.loggable'; import { ErrorLogger } from './error-logger'; @@ -28,6 +29,45 @@ describe('ErrorLogger', () => { await module.close(); }); + describe('emerg', () => { + it('should call emerg method of WinstonLogger with appropriate message', () => { + const error = new Error('test'); + const loggable = new ErrorLoggable(error); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedMessage = expect.objectContaining({ message: expect.stringContaining('error: Error: test') }); + + service.emerg(loggable); + + expect(winstonLogger.emerg).toBeCalledWith(expectedMessage); + }); + }); + + describe('alert', () => { + it('should call alert method of WinstonLogger with appropriate message', () => { + const error = new Error('test'); + const loggable = new ErrorLoggable(error); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedMessage = expect.objectContaining({ message: expect.stringContaining('error: Error: test') }); + + service.alert(loggable); + + expect(winstonLogger.alert).toBeCalledWith(expectedMessage); + }); + }); + + describe('crit', () => { + it('should call crit method of WinstonLogger with appropriate message', () => { + const error = new Error('test'); + const loggable = new ErrorLoggable(error); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedMessage = expect.objectContaining({ message: expect.stringContaining('error: Error: test') }); + + service.crit(loggable); + + expect(winstonLogger.crit).toBeCalledWith(expectedMessage); + }); + }); + describe('error', () => { it('should call error method of WinstonLogger with appropriate message', () => { const error = new Error('test'); diff --git a/apps/server/src/core/logger/error-logger.ts b/apps/server/src/core/logger/error-logger.ts index e36a168d15f..1b7dd711765 100644 --- a/apps/server/src/core/logger/error-logger.ts +++ b/apps/server/src/core/logger/error-logger.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { WinstonLogger, WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger as WinstonLogger } from 'winston'; import { Loggable } from './interfaces'; import { LoggingUtils } from './logging.utils'; @@ -8,6 +9,21 @@ import { LoggingUtils } from './logging.utils'; export class ErrorLogger { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: WinstonLogger) {} + emerg(loggable: Loggable): void { + const message = LoggingUtils.createMessageWithContext(loggable); + this.logger.emerg(message); + } + + alert(loggable: Loggable): void { + const message = LoggingUtils.createMessageWithContext(loggable); + this.logger.alert(message); + } + + crit(loggable: Loggable): void { + const message = LoggingUtils.createMessageWithContext(loggable); + this.logger.crit(message); + } + error(loggable: Loggable): void { const message = LoggingUtils.createMessageWithContext(loggable); this.logger.error(message); diff --git a/apps/server/src/core/logger/interfaces/legacy-logger.interface.ts b/apps/server/src/core/logger/interfaces/legacy-logger.interface.ts index 9b9ad8ea290..5f88f503de1 100644 --- a/apps/server/src/core/logger/interfaces/legacy-logger.interface.ts +++ b/apps/server/src/core/logger/interfaces/legacy-logger.interface.ts @@ -13,5 +13,4 @@ export interface ILegacyLogger { error(message: unknown, trace?: string, context?: string): void; warn(message: unknown, context?: string): void; debug(message: unknown, context?: string): void; - verbose?(message: unknown, context?: string): void; } diff --git a/apps/server/src/core/logger/legacy-logger.service.spec.ts b/apps/server/src/core/logger/legacy-logger.service.spec.ts index b318dead5eb..5ed646581e2 100644 --- a/apps/server/src/core/logger/legacy-logger.service.spec.ts +++ b/apps/server/src/core/logger/legacy-logger.service.spec.ts @@ -72,12 +72,4 @@ describe('LegacyLogger', () => { expect(winstonLogger.debug).toBeCalled(); }); }); - - describe('WHEN verbose logging', () => { - it('should call winstonLogger.verbose', () => { - const error = new Error('custom error'); - service.verbose(error.message, error.stack); - expect(winstonLogger.verbose).toBeCalled(); - }); - }); }); diff --git a/apps/server/src/core/logger/legacy-logger.service.ts b/apps/server/src/core/logger/legacy-logger.service.ts index f39cd2dda21..e8b8139e041 100644 --- a/apps/server/src/core/logger/legacy-logger.service.ts +++ b/apps/server/src/core/logger/legacy-logger.service.ts @@ -24,7 +24,7 @@ export class LegacyLogger implements ILegacyLogger { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: WinstonLogger) {} log(message: unknown, context?: string): void { - this.logger.log('info', this.createMessage(message, context)); + this.logger.info(this.createMessage(message, context)); } warn(message: unknown, context?: string): void { @@ -35,10 +35,6 @@ export class LegacyLogger implements ILegacyLogger { this.logger.debug(this.createMessage(message, context)); } - verbose(message: unknown, context?: string): void { - this.logger.verbose(this.createMessage(message, context)); - } - http(message: RequestLoggingBody, context?: string): void { this.logger.notice(this.createMessage(message, context)); } diff --git a/apps/server/src/core/logger/logger.spec.ts b/apps/server/src/core/logger/logger.spec.ts index 8fe50616c26..bea175bb070 100644 --- a/apps/server/src/core/logger/logger.spec.ts +++ b/apps/server/src/core/logger/logger.spec.ts @@ -45,7 +45,7 @@ describe('Logger', () => { await module.close(); }); - describe('log', () => { + describe('info', () => { it('should call info method of WinstonLogger with appropriate message', () => { const loggable = new SampleLoggable(); service.setContext('test context'); @@ -56,7 +56,7 @@ describe('Logger', () => { context: 'test context', }); - service.log(loggable); + service.info(loggable); expect(winstonLogger.info).toBeCalledWith(expectedMessage); }); @@ -73,7 +73,7 @@ describe('Logger', () => { context: 'test context', }); - service.warn(loggable); + service.warning(loggable); expect(winstonLogger.warning).toBeCalledWith(expectedMessage); }); @@ -96,8 +96,17 @@ describe('Logger', () => { }); }); - describe('verbose', () => { - it('should call verbose method of WinstonLogger with appropriate message', () => { + describe('setContext', () => { + it('should set the context', () => { + service.setContext('test'); + + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(service['context']).toEqual('test'); + }); + }); + + describe('notice', () => { + it('should call notice method of WinstonLogger with appropriate message', () => { const loggable = new SampleLoggable(); service.setContext('test context'); @@ -107,18 +116,9 @@ describe('Logger', () => { context: 'test context', }); - service.verbose(loggable); + service.notice(loggable); - expect(winstonLogger.verbose).toBeCalledWith(expectedMessage); - }); - }); - - describe('setContext', () => { - it('should set the context', () => { - service.setContext('test'); - - // eslint-disable-next-line @typescript-eslint/dot-notation - expect(service['context']).toEqual('test'); + expect(winstonLogger.notice).toBeCalledWith(expectedMessage); }); }); }); diff --git a/apps/server/src/core/logger/logger.ts b/apps/server/src/core/logger/logger.ts index 77a81d6c052..2ad8daa6bf1 100644 --- a/apps/server/src/core/logger/logger.ts +++ b/apps/server/src/core/logger/logger.ts @@ -10,24 +10,24 @@ export class Logger { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: WinstonLogger) {} - public log(loggable: Loggable): void { + public warning(loggable: Loggable): void { const message = LoggingUtils.createMessageWithContext(loggable, this.context); - this.logger.info(message); + this.logger.warning(message); } - public warn(loggable: Loggable): void { + public notice(loggable: Loggable): void { const message = LoggingUtils.createMessageWithContext(loggable, this.context); - this.logger.warning(message); + this.logger.notice(message); } - public debug(loggable: Loggable): void { + public info(loggable: Loggable): void { const message = LoggingUtils.createMessageWithContext(loggable, this.context); - this.logger.debug(message); + this.logger.info(message); } - public verbose(loggable: Loggable): void { + public debug(loggable: Loggable): void { const message = LoggingUtils.createMessageWithContext(loggable, this.context); - this.logger.verbose(message); + this.logger.debug(message); } public setContext(name: string) { diff --git a/apps/server/src/core/validation/pipe/global-validation.pipe.ts b/apps/server/src/core/validation/pipe/global-validation.pipe.ts index dd37e1355da..089f0120013 100644 --- a/apps/server/src/core/validation/pipe/global-validation.pipe.ts +++ b/apps/server/src/core/validation/pipe/global-validation.pipe.ts @@ -22,6 +22,13 @@ export class GlobalValidationPipe extends ValidationPipe { forbidNonWhitelisted: false, // additional params are just skipped (required when extracting multiple DTO from single query) forbidUnknownValues: true, exceptionFactory: (errors: ValidationError[]) => new ApiValidationError(errors), + validationError: { + // make sure target (DTO) is set on validation error + // we need this to be able to get DTO metadata for checking if a value has to be the obfuscated on output + // see e.g. ErrorLoggable + target: true, + value: true, + }, }); } } diff --git a/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts b/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts index 2ab4c90aa56..4ad564f17b2 100644 --- a/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts +++ b/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { PrivacyProtect } from '@shared/controller'; import { IsBoolean, IsString, IsOptional, Matches, IsEmail } from 'class-validator'; import { passwordPattern } from './password-pattern'; @@ -13,6 +14,7 @@ export class AccountByIdBodyParams { }) username?: string; + @PrivacyProtect() @IsOptional() @IsString() @Matches(passwordPattern) diff --git a/apps/server/src/modules/account/controller/dto/account.response.ts b/apps/server/src/modules/account/controller/dto/account.response.ts index cb0fff602a8..25d28ca2765 100644 --- a/apps/server/src/modules/account/controller/dto/account.response.ts +++ b/apps/server/src/modules/account/controller/dto/account.response.ts @@ -1,11 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; export class AccountResponse { - constructor({ id, username, userId, activated }: AccountResponse) { + constructor({ id, username, userId, activated, updatedAt }: AccountResponse) { this.id = id; this.username = username; this.userId = userId; this.activated = activated; + this.updatedAt = updatedAt; } @ApiProperty() @@ -19,4 +20,7 @@ export class AccountResponse { @ApiProperty() activated?: boolean; + + @ApiProperty() + updatedAt?: Date; } diff --git a/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts b/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts index 2bd0da40a88..28874bb255a 100644 --- a/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts +++ b/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { PrivacyProtect } from '@shared/controller'; import { IsEmail, IsOptional, IsString, Matches } from 'class-validator'; import { passwordPattern } from './password-pattern'; @@ -11,6 +12,7 @@ export class PatchMyAccountParams { }) passwordOld!: string; + @PrivacyProtect() @IsString() @IsOptional() @Matches(passwordPattern) diff --git a/apps/server/src/modules/account/controller/dto/patch-my-password.params.ts b/apps/server/src/modules/account/controller/dto/patch-my-password.params.ts index 08cb2cb4e24..c795e4bf8a2 100644 --- a/apps/server/src/modules/account/controller/dto/patch-my-password.params.ts +++ b/apps/server/src/modules/account/controller/dto/patch-my-password.params.ts @@ -1,8 +1,10 @@ -import { IsString, Matches } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { PrivacyProtect } from '@shared/controller'; +import { IsString, Matches } from 'class-validator'; import { passwordPattern } from './password-pattern'; export class PatchMyPasswordParams { + @PrivacyProtect() @IsString() @Matches(passwordPattern) @ApiProperty({ @@ -12,6 +14,7 @@ export class PatchMyPasswordParams { }) password!: string; + @PrivacyProtect() @IsString() @Matches(passwordPattern) @ApiProperty({ diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts index 93d6fabe57a..6fb7e64ac09 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { AccountDto } from '../services/dto/account.dto'; @Injectable() export abstract class AccountIdmToDtoMapper { - abstract mapToDto(account: IAccount): AccountDto; + abstract mapToDto(account: IdmAccount): AccountDto; } diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts index c4a9e572d98..2430afe6081 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { AccountDto } from '../services/dto'; import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; import { AccountIdmToDtoMapperDb } from './account-idm-to-dto.mapper.db'; @@ -27,25 +27,25 @@ describe('AccountIdmToDtoMapperDb', () => { describe('when mapping from entity to dto', () => { describe('mapToDto', () => { it('should map all fields', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', username: 'username', email: 'email', firstName: 'firstName', lastName: 'lastName', createdDate: new Date(), - attRefTechnicalId: 'attRefTechnicalId', - attRefFunctionalIntId: 'attRefFunctionalIntId', - attRefFunctionalExtId: 'attRefFunctionalExtId', + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', }; const ret = mapper.mapToDto(testIdmEntity); expect(ret).toEqual( expect.objectContaining>({ - id: testIdmEntity.attRefTechnicalId, + id: testIdmEntity.attDbcAccountId, idmReferenceId: testIdmEntity.id, - userId: testIdmEntity.attRefFunctionalIntId, - systemId: testIdmEntity.attRefFunctionalExtId, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, createdAt: testIdmEntity.createdDate, updatedAt: testIdmEntity.createdDate, username: testIdmEntity.username, @@ -55,7 +55,7 @@ describe('AccountIdmToDtoMapperDb', () => { describe('when date is undefined', () => { it('should use actual date', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', }; const ret = mapper.mapToDto(testIdmEntity); @@ -68,7 +68,7 @@ describe('AccountIdmToDtoMapperDb', () => { describe('when a fields value is missing', () => { it('should fill with empty string', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', }; const ret = mapper.mapToDto(testIdmEntity); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts index 3f039637d69..0dd4877d59d 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts @@ -1,15 +1,15 @@ -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { AccountDto } from '../services/dto/account.dto'; import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; export class AccountIdmToDtoMapperDb extends AccountIdmToDtoMapper { - mapToDto(account: IAccount): AccountDto { + mapToDto(account: IdmAccount): AccountDto { const createdDate = account.createdDate ? account.createdDate : new Date(); return new AccountDto({ - id: account.attRefTechnicalId ?? '', + id: account.attDbcAccountId ?? '', idmReferenceId: account.id, - userId: account.attRefFunctionalIntId, - systemId: account.attRefFunctionalExtId, + userId: account.attDbcUserId, + systemId: account.attDbcSystemId, username: account.username ?? '', createdAt: createdDate, updatedAt: createdDate, diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts index b700f759667..0d60a2cc57f 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { AccountDto } from '../services/dto'; import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; import { AccountIdmToDtoMapperIdm } from './account-idm-to-dto.mapper.idm'; @@ -32,16 +32,16 @@ describe('AccountIdmToDtoMapperIdm', () => { describe('when mapping from entity to dto', () => { it('should map all fields', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', username: 'username', email: 'email', firstName: 'firstName', lastName: 'lastName', createdDate: new Date(), - attRefTechnicalId: 'attRefTechnicalId', - attRefFunctionalIntId: 'attRefFunctionalIntId', - attRefFunctionalExtId: 'attRefFunctionalExtId', + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', }; const ret = mapper.mapToDto(testIdmEntity); @@ -49,8 +49,8 @@ describe('AccountIdmToDtoMapperIdm', () => { expect.objectContaining>({ id: testIdmEntity.id, idmReferenceId: undefined, - userId: testIdmEntity.attRefFunctionalIntId, - systemId: testIdmEntity.attRefFunctionalExtId, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, createdAt: testIdmEntity.createdDate, updatedAt: testIdmEntity.createdDate, username: testIdmEntity.username, @@ -60,7 +60,7 @@ describe('AccountIdmToDtoMapperIdm', () => { describe('when date is undefined', () => { it('should use actual date', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', }; const ret = mapper.mapToDto(testIdmEntity); @@ -72,7 +72,7 @@ describe('AccountIdmToDtoMapperIdm', () => { describe('when a fields value is missing', () => { it('should fill with empty string', () => { - const testIdmEntity: IAccount = { + const testIdmEntity: IdmAccount = { id: 'id', }; const ret = mapper.mapToDto(testIdmEntity); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts index 8a0a770c6ac..4f30fac7158 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts @@ -1,15 +1,15 @@ -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { AccountDto } from '../services/dto/account.dto'; import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; export class AccountIdmToDtoMapperIdm extends AccountIdmToDtoMapper { - mapToDto(account: IAccount): AccountDto { + mapToDto(account: IdmAccount): AccountDto { const createdDate = account.createdDate ? account.createdDate : new Date(); return new AccountDto({ id: account.id, idmReferenceId: undefined, - userId: account.attRefFunctionalIntId, - systemId: account.attRefFunctionalExtId, + userId: account.attDbcUserId, + systemId: account.attDbcSystemId, username: account.username ?? '', createdAt: createdDate, updatedAt: createdDate, diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts index a2eb6be29b1..e3aa1d06c03 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts @@ -21,6 +21,7 @@ describe('AccountResponseMapper', () => { expect(ret.userId).toBe(testEntity.userId?.toString()); expect(ret.activated).toBe(testEntity.activated); expect(ret.username).toBe(testEntity.username); + expect(ret.updatedAt).toBe(testEntity.updatedAt); }); it('should ignore missing userId', () => { @@ -55,6 +56,7 @@ describe('AccountResponseMapper', () => { expect(ret.userId).toBe(testDto.userId?.toString()); expect(ret.activated).toBe(testDto.activated); expect(ret.username).toBe(testDto.username); + expect(ret.updatedAt).toBe(testDto.updatedAt); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.ts b/apps/server/src/modules/account/mapper/account-response.mapper.ts index 3ecbc110add..12d9227163b 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.ts @@ -9,6 +9,7 @@ export class AccountResponseMapper { userId: account.userId?.toString(), activated: account.activated, username: account.username, + updatedAt: account.updatedAt, }); } @@ -18,6 +19,7 @@ export class AccountResponseMapper { userId: account.userId, activated: account.activated, username: account.username, + updatedAt: account.updatedAt, }); } } 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 29144af7601..82f54c60fa1 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 @@ -3,7 +3,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-ad import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { KeycloakAdministrationService } from '@shared/infra/identity-management/keycloak-administration/service/keycloak-administration.service'; import { AccountSaveDto } from '@src/modules/account/services/dto'; import { LoggerModule } from '@src/core/logger'; @@ -23,21 +23,21 @@ describe('AccountIdmService Integration', () => { let accountIdmService: AbstractAccountService; const testRealm = `test-realm-${v1()}`; - const technicalRefId = new ObjectId().toString(); + const testDbcAccountId = new ObjectId().toString(); const testAccount = new AccountSaveDto({ username: 'john.doe@mail.tld', password: 'super-secret-password', userId: new ObjectId().toString(), systemId: new ObjectId().toString(), - idmReferenceId: technicalRefId, + idmReferenceId: testDbcAccountId, }); const createAccount = async (): Promise => identityManagementService.createAccount( { username: testAccount.username, - attRefFunctionalIntId: testAccount.userId, - attRefFunctionalExtId: testAccount.systemId, - attRefTechnicalId: technicalRefId, + attDbcUserId: testAccount.userId, + attDbcSystemId: testAccount.systemId, + attDbcAccountId: testDbcAccountId, }, testAccount.password ); @@ -100,12 +100,12 @@ describe('AccountIdmService Integration', () => { const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); expect(foundAccount).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: createdAccount.idmReferenceId ?? '', username: createdAccount.username, - attRefTechnicalId: technicalRefId, - attRefFunctionalIntId: createdAccount.userId, - attRefFunctionalExtId: createdAccount.systemId, + attDbcAccountId: testDbcAccountId, + attDbcUserId: createdAccount.userId, + attDbcSystemId: createdAccount.systemId, }) ); }); @@ -116,13 +116,13 @@ describe('AccountIdmService Integration', () => { const idmId = await createAccount(); await accountIdmService.save({ - id: technicalRefId, + id: testDbcAccountId, username: newUsername, }); const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: idmId, username: newUsername, }) @@ -133,12 +133,12 @@ describe('AccountIdmService Integration', () => { if (!isIdmReachable) return; const newUserName = 'jane.doe@mail.tld'; const idmId = await createAccount(); - await accountIdmService.updateUsername(technicalRefId, newUserName); + await accountIdmService.updateUsername(testDbcAccountId, newUserName); const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ username: newUserName, }) ); @@ -147,7 +147,7 @@ describe('AccountIdmService Integration', () => { it('updatePassword should update password', async () => { if (!isIdmReachable) return; await createAccount(); - await expect(accountIdmService.updatePassword(technicalRefId, 'newPassword')).resolves.not.toThrow(); + await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); }); it('delete should remove account', async () => { @@ -156,7 +156,7 @@ describe('AccountIdmService Integration', () => { const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toBeDefined(); - await accountIdmService.delete(technicalRefId); + await accountIdmService.delete(testDbcAccountId); await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); }); 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 20126c9c313..4b997d1b3fe 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 @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { IdentityManagementOauthService, IdentityManagementService } from '@shared/infra/identity-management'; import { NotImplementedException } from '@nestjs/common'; @@ -20,17 +20,17 @@ describe('AccountIdmService', () => { let accountLookupServiceMock: DeepMocked; let idmOauthServiceMock: DeepMocked; - const mockIdmAccountRefId = 'tecId'; - const mockIdmAccount: IAccount = { + const mockIdmAccountRefId = 'dbcAccountId'; + const mockIdmAccount: IdmAccount = { id: 'id', username: 'username', email: 'email', firstName: 'firstName', lastName: 'lastName', createdDate: new Date(2020, 1, 1, 0, 0, 0, 0), - attRefTechnicalId: mockIdmAccountRefId, - attRefFunctionalIntId: 'fctIntId', - attRefFunctionalExtId: 'fctExtId', + attDbcAccountId: mockIdmAccountRefId, + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', }; beforeAll(async () => { @@ -101,7 +101,7 @@ describe('AccountIdmService', () => { expect(ret).toBeDefined(); expect(ret).toMatchObject>({ - id: mockIdmAccount.attRefTechnicalId, + id: mockIdmAccount.attDbcAccountId, idmReferenceId: mockIdmAccount.id, createdAt: mockIdmAccount.createdDate, updatedAt: mockIdmAccount.createdDate, @@ -141,7 +141,7 @@ describe('AccountIdmService', () => { expect(ret).toBeDefined(); expect(ret).toMatchObject>({ - id: mockIdmAccount.attRefTechnicalId, + id: mockIdmAccount.attDbcAccountId, idmReferenceId: mockIdmAccount.id, createdAt: mockIdmAccount.createdDate, updatedAt: mockIdmAccount.createdDate, @@ -163,7 +163,7 @@ describe('AccountIdmService', () => { expect(idmServiceMock.createAccount).toHaveBeenCalled(); expect(ret).toBeDefined(); expect(ret).toMatchObject>({ - id: mockIdmAccount.attRefTechnicalId, + id: mockIdmAccount.attDbcAccountId, idmReferenceId: mockIdmAccount.id, createdAt: mockIdmAccount.createdDate, updatedAt: mockIdmAccount.createdDate, @@ -179,7 +179,7 @@ describe('AccountIdmService', () => { expect(ret).toBeDefined(); expect(ret).toMatchObject>({ - id: mockIdmAccount.attRefTechnicalId, + id: mockIdmAccount.attDbcAccountId, idmReferenceId: mockIdmAccount.id, createdAt: mockIdmAccount.createdDate, updatedAt: mockIdmAccount.createdDate, @@ -195,7 +195,7 @@ describe('AccountIdmService', () => { expect(ret).toBeDefined(); expect(ret).toMatchObject>({ - id: mockIdmAccount.attRefTechnicalId, + id: mockIdmAccount.attDbcAccountId, idmReferenceId: mockIdmAccount.id, createdAt: mockIdmAccount.createdDate, updatedAt: mockIdmAccount.createdDate, @@ -257,14 +257,14 @@ describe('AccountIdmService', () => { describe('deleteByUserId', () => { const setup = () => { - idmServiceMock.findAccountByFctIntId.mockResolvedValue(mockIdmAccount); + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); }; it('should delete the account with given user id via repo', async () => { setup(); const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); - await accountIdmService.deleteByUserId(mockIdmAccount.attRefFunctionalIntId ?? ''); + await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); }); }); @@ -298,8 +298,8 @@ describe('AccountIdmService', () => { describe('when finding accounts', () => { const setup = () => { const accounts = [mockIdmAccount]; - idmServiceMock.findAccountByFctIntId.mockImplementation(() => { - const element = accounts.pop() as IAccount; + idmServiceMock.findAccountByDbcUserId.mockImplementation(() => { + const element = accounts.pop() as IdmAccount; if (element) { return Promise.resolve(element); } @@ -318,19 +318,19 @@ describe('AccountIdmService', () => { describe('findByUserId', () => { describe('when finding an account', () => { const setup = () => { - idmServiceMock.findAccountByFctIntId.mockResolvedValue(mockIdmAccount); + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); }; it('should return the account', async () => { setup(); - const result = await accountIdmService.findByUserId(mockIdmAccount.attRefFunctionalIntId ?? ''); + const result = await accountIdmService.findByUserId(mockIdmAccount.attDbcUserId ?? ''); expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); }); }); describe('when not finding an account', () => { const setup = () => { - idmServiceMock.findAccountByFctIntId.mockResolvedValue(undefined as unknown as IAccount); + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(undefined as unknown as IdmAccount); }; it('should return null', async () => { @@ -344,7 +344,7 @@ describe('AccountIdmService', () => { describe('findByUserIdOrFail', () => { describe('when finding an account', () => { const setup = () => { - idmServiceMock.findAccountByFctIntId.mockResolvedValue(mockIdmAccount); + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); }; it('should return the account', async () => { @@ -356,7 +356,7 @@ describe('AccountIdmService', () => { describe('when not finding an account', () => { const setup = () => { - idmServiceMock.findAccountByFctIntId.mockResolvedValue(undefined as unknown as IAccount); + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(undefined as unknown as IdmAccount); }; it('should throw', async () => { @@ -374,7 +374,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); - const result = await accountIdmService.findByUsernameAndSystemId('username', 'fctExtId'); + const result = await accountIdmService.findByUsernameAndSystemId('username', 'attDbcSystemId'); expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); }); }); @@ -447,7 +447,7 @@ describe('AccountIdmService', () => { describe('updateLastTriedFailedLogin', () => { describe('when updating the last tried failed login', () => { const setup = () => { - idmServiceMock.findAccountByTecRefId.mockResolvedValue(mockIdmAccount); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); }; 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 f0d556c6fe5..68bcfb42bae 100644 --- a/apps/server/src/modules/account/services/account-idm.service.ts +++ b/apps/server/src/modules/account/services/account-idm.service.ts @@ -1,7 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, NotImplementedException } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; -import { Counted, EntityId, IAccount, IAccountUpdate } from '@shared/domain'; +import { Counted, EntityId, IdmAccount, IdmAccountUpdate } from '@shared/domain'; import { IdentityManagementService, IdentityManagementOauthService } from '@shared/infra/identity-management'; import { LegacyLogger } from '@src/core/logger'; import { AccountIdmToDtoMapper } from '../mapper'; @@ -28,11 +28,11 @@ export class AccountServiceIdm extends AbstractAccountService { } async findMultipleByUserId(userIds: EntityId[]): Promise { - const results = new Array(); + const results = new Array(); for (const userId of userIds) { try { // eslint-disable-next-line no-await-in-loop - results.push(await this.identityManager.findAccountByFctIntId(userId)); + results.push(await this.identityManager.findAccountByDbcUserId(userId)); } catch { // ignore entry } @@ -43,7 +43,7 @@ export class AccountServiceIdm extends AbstractAccountService { async findByUserId(userId: EntityId): Promise { try { - const result = await this.identityManager.findAccountByFctIntId(userId); + const result = await this.identityManager.findAccountByDbcUserId(userId); return this.accountIdmToDtoMapper.mapToDto(result); } catch { return null; @@ -52,7 +52,7 @@ export class AccountServiceIdm extends AbstractAccountService { async findByUserIdOrFail(userId: EntityId): Promise { try { - const result = await this.identityManager.findAccountByFctIntId(userId); + const result = await this.identityManager.findAccountByDbcUserId(userId); return this.accountIdmToDtoMapper.mapToDto(result); } catch { throw new EntityNotFoundError(`Account with userId ${userId} not found`); @@ -87,11 +87,11 @@ export class AccountServiceIdm extends AbstractAccountService { async save(accountDto: AccountSaveDto): Promise { let accountId: string; - const idmAccount: IAccountUpdate = { + const idmAccount: IdmAccountUpdate = { username: accountDto.username, - attRefTechnicalId: accountDto.idmReferenceId, - attRefFunctionalIntId: accountDto.userId, - attRefFunctionalExtId: accountDto.systemId, + attDbcAccountId: accountDto.idmReferenceId, + attDbcUserId: accountDto.userId, + attDbcSystemId: accountDto.systemId, }; if (accountDto.id) { let idmId: string | undefined; @@ -114,7 +114,7 @@ export class AccountServiceIdm extends AbstractAccountService { return this.accountIdmToDtoMapper.mapToDto(updatedAccount); } - private async updateAccount(idmAccountId: string, idmAccount: IAccountUpdate, password?: string): Promise { + private async updateAccount(idmAccountId: string, idmAccount: IdmAccountUpdate, password?: string): Promise { const updatedAccountId = await this.identityManager.updateAccount(idmAccountId, idmAccount); if (password) { await this.identityManager.updateAccountPassword(idmAccountId, password); @@ -122,7 +122,7 @@ export class AccountServiceIdm extends AbstractAccountService { return updatedAccountId; } - private async createAccount(idmAccount: IAccountUpdate, password?: string): Promise { + private async createAccount(idmAccount: IdmAccountUpdate, password?: string): Promise { const accountId = await this.identityManager.createAccount(idmAccount, password); return accountId; } @@ -152,7 +152,7 @@ export class AccountServiceIdm extends AbstractAccountService { } async deleteByUserId(userId: EntityId): Promise { - const idmAccount = await this.identityManager.findAccountByFctIntId(userId); + const idmAccount = await this.identityManager.findAccountByDbcUserId(userId); await this.identityManager.deleteAccountById(idmAccount.id); } 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 90215634a2a..cfef246d3e3 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 @@ -3,7 +3,7 @@ import { v1 } from 'uuid'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { IAccount } from '@shared/domain'; +import { IdmAccount } from '@shared/domain'; import { IdentityManagementService } from '@shared/infra/identity-management'; import { AccountLookupService } from './account-lookup.service'; @@ -16,9 +16,9 @@ describe('AccountLookupService', () => { const internalId = new ObjectId().toHexString(); const internalIdAsObjectId = new ObjectId(internalId); const externalId = v1(); - const accountMock: IAccount = { + const accountMock: IdmAccount = { id: externalId, - attRefTechnicalId: internalId, + attDbcAccountId: internalId, }; beforeAll(async () => { @@ -74,7 +74,7 @@ describe('AccountLookupService', () => { setup(); const result = await accountLookupService.getInternalId(accountMock.id); expect(result).toBeInstanceOf(ObjectId); - expect(result?.toHexString()).toBe(accountMock.attRefTechnicalId); + expect(result?.toHexString()).toBe(accountMock.attDbcAccountId); }); }); @@ -102,7 +102,7 @@ describe('AccountLookupService', () => { describe('when id is an internal id and FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED is enabled', () => { const setup = () => { configServiceMock.get.mockReturnValue(true); - idmServiceMock.findAccountByTecRefId.mockResolvedValue(accountMock); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(accountMock); }; it('should return the external id', async () => { 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 a6c6050c034..a0e18870177 100644 --- a/apps/server/src/modules/account/services/account-lookup.service.ts +++ b/apps/server/src/modules/account/services/account-lookup.service.ts @@ -30,7 +30,7 @@ export class AccountLookupService { } if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { const account = await this.idmService.findAccountById(id); - return new ObjectId(account.attRefTechnicalId); + return new ObjectId(account.attDbcAccountId); } return null; } @@ -46,7 +46,7 @@ export class AccountLookupService { return id; } if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { - const account = await this.idmService.findAccountByTecRefId(id.toString()); + const account = await this.idmService.findAccountByDbcAccountId(id.toString()); return account.id; } return null; 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 9ed14236061..d001925000b 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 @@ -3,7 +3,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-ad import { EntityManager } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, IAccount } from '@shared/domain'; +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'; @@ -55,9 +55,9 @@ describe('AccountService Integration', () => { const idmId = await identityManagementService.createAccount( { username: testAccount.username, - attRefFunctionalIntId: testAccount.userId, - attRefFunctionalExtId: testAccount.systemId, - attRefTechnicalId: refId, + attDbcUserId: testAccount.userId, + attDbcSystemId: testAccount.systemId, + attDbcAccountId: refId, }, testAccount.password ); @@ -137,12 +137,12 @@ describe('AccountService Integration', () => { const compareIdmAccount = async (idmId: string, createdAccount: AccountDto): Promise => { const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: createdAccount.idmReferenceId ?? '', username: createdAccount.username, - attRefTechnicalId: createdAccount.id, - attRefFunctionalIntId: createdAccount.userId, - attRefFunctionalExtId: createdAccount.systemId, + attDbcAccountId: createdAccount.id, + attDbcUserId: createdAccount.userId, + attDbcSystemId: createdAccount.systemId, }) ); }; @@ -199,7 +199,7 @@ describe('AccountService Integration', () => { const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ username: newUserName, }) ); diff --git a/apps/server/src/modules/account/services/dto/account-save.dto.ts b/apps/server/src/modules/account/services/dto/account-save.dto.ts index 834bd23d409..10262dd9478 100644 --- a/apps/server/src/modules/account/services/dto/account-save.dto.ts +++ b/apps/server/src/modules/account/services/dto/account-save.dto.ts @@ -1,5 +1,6 @@ -import { IsOptional, IsMongoId, IsString, Matches, IsNotEmpty, IsBoolean, IsDate } from 'class-validator'; +import { PrivacyProtect } from '@shared/controller'; import { EntityId } from '@shared/domain'; +import { IsBoolean, IsDate, IsMongoId, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; import { passwordPattern } from '../../controller/dto/password-pattern'; export class AccountSaveDto { @@ -19,6 +20,7 @@ export class AccountSaveDto { @IsNotEmpty() username: string; + @PrivacyProtect() @IsOptional() @Matches(passwordPattern) password?: string; diff --git a/apps/server/src/modules/authentication/services/ldap.service.spec.ts b/apps/server/src/modules/authentication/services/ldap.service.spec.ts index aad9c81ce3f..83e6a212d76 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.spec.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.spec.ts @@ -79,7 +79,7 @@ describe('LdapService', () => { it('should throw unauthorized error', async () => { const system: System = systemFactory.withLdapConfig().buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( - new UnauthorizedException('an error', 'User could not authenticate') + new UnauthorizedException('User could not authenticate') ); }); }); diff --git a/apps/server/src/modules/authentication/services/ldap.service.ts b/apps/server/src/modules/authentication/services/ldap.service.ts index a425e804f0c..4a875ce70a8 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.ts @@ -1,7 +1,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { System } from '@shared/domain'; -import { Client, createClient } from 'ldapjs'; +import { ErrorUtils } from '@src/core/error/utils'; import { LegacyLogger } from '@src/core/logger'; +import { Client, createClient } from 'ldapjs'; import { LdapConnectionError } from '../errors/ldap-connection.error'; @Injectable() @@ -37,7 +38,12 @@ export class LdapService { client.bind(username, password, (err) => { if (err) { this.logger.debug(err); - reject(new UnauthorizedException(err, 'User could not authenticate')); + reject( + new UnauthorizedException( + 'User could not authenticate', + ErrorUtils.createHttpExceptionOptions(err, 'LdapService:connect') + ) + ); } else { this.logger.debug('[LDAP] Bind successful'); resolve(client); 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 1ed62962333..936deb866e4 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 @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { CACHE_MANAGER } from '@nestjs/common'; +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'; 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 2177d631abc..3af5db2061b 100644 --- a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts +++ b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.ts @@ -1,4 +1,5 @@ -import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; +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 { diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index a7f5ab036e5..3e5327c62aa 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -98,7 +98,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { account = await this.authenticationService.loadAccount(`${externalSchoolId}/${username}`.toLowerCase(), systemId); } catch (err: unknown) { if (school.previousExternalId) { - this.logger.log( + this.logger.info( new ErrorLoggable( new Error( `Could not find LDAP account with externalSchoolId ${externalSchoolId} for user ${username}. Trying to use the previousExternalId ${school.previousExternalId} next...` diff --git a/apps/server/src/modules/authorization/authorization.helper.spec.ts b/apps/server/src/modules/authorization/authorization.helper.spec.ts index 579c4f28361..f1c166d6dd4 100644 --- a/apps/server/src/modules/authorization/authorization.helper.spec.ts +++ b/apps/server/src/modules/authorization/authorization.helper.spec.ts @@ -183,10 +183,9 @@ describe('AuthorizationHelper', () => { describe('when several props are given', () => { it('should return true if the user is referenced in at least one prop', () => { const user = userFactory.build(); - const user2 = userFactory.build(); - const task = taskFactory.build({ creator: user, users: [user2] }); + const task = taskFactory.build({ creator: user }); - const permissions = service.hasAccessToEntity(user, task, ['creator', 'users']); + const permissions = service.hasAccessToEntity(user, task, ['creator']); expect(permissions).toEqual(true); }); @@ -194,10 +193,9 @@ describe('AuthorizationHelper', () => { it('should return false if the user is referenced in none of the props', () => { const user = userFactory.build(); const user2 = userFactory.build(); - const user3 = userFactory.build(); - const task = taskFactory.build({ creator: user, users: [user2] }); + const task = taskFactory.build({ creator: user }); - const permissions = service.hasAccessToEntity(user3, task, ['creator', 'users']); + const permissions = service.hasAccessToEntity(user2, task, ['creator']); expect(permissions).toEqual(false); }); diff --git a/apps/server/src/modules/authorization/authorization.service.ts b/apps/server/src/modules/authorization/authorization.service.ts index 65c18365b66..b89561f1c30 100644 --- a/apps/server/src/modules/authorization/authorization.service.ts +++ b/apps/server/src/modules/authorization/authorization.service.ts @@ -1,6 +1,7 @@ import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BaseDO, EntityId, User } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; +import { ErrorUtils } from '@src/core/error/utils'; import { AuthorizationHelper } from './authorization.helper'; import { ForbiddenLoggableException } from './errors/forbidden.loggable-exception'; import { ReferenceLoader } from './reference.loader'; @@ -61,8 +62,11 @@ export class AuthorizationService { const hasPermission = rule.hasPermission(user, object, context); return hasPermission; - } catch (err) { - throw new ForbiddenException(err); + } catch (error) { + throw new ForbiddenException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'AuthorizationService:hasPermissionByReferences') + ); } } diff --git a/apps/server/src/modules/authorization/reference.loader.spec.ts b/apps/server/src/modules/authorization/reference.loader.spec.ts index 4be54978106..76501434a4e 100644 --- a/apps/server/src/modules/authorization/reference.loader.spec.ts +++ b/apps/server/src/modules/authorization/reference.loader.spec.ts @@ -154,7 +154,7 @@ describe('reference.loader', () => { }); it('should call contextExternalToolService.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.ContextExternalTool, entityId); + await service.loadAuthorizableObject(AuthorizableReferenceType.ContextExternalToolEntity, entityId); expect(contextExternalToolAuthorizableService.findById).toBeCalledWith(entityId); }); @@ -166,7 +166,7 @@ describe('reference.loader', () => { }); it('should call schoolExternalToolRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.SchoolExternalTool, entityId); + await service.loadAuthorizableObject(AuthorizableReferenceType.SchoolExternalToolEntity, entityId); expect(schoolExternalToolRepo.findById).toBeCalledWith(entityId); }); diff --git a/apps/server/src/modules/authorization/reference.loader.ts b/apps/server/src/modules/authorization/reference.loader.ts index c3fd90fdfe6..550f1f02762 100644 --- a/apps/server/src/modules/authorization/reference.loader.ts +++ b/apps/server/src/modules/authorization/reference.loader.ts @@ -60,9 +60,9 @@ export class ReferenceLoader { this.repos.set(AuthorizableReferenceType.Lesson, { repo: this.lessonRepo }); this.repos.set(AuthorizableReferenceType.Team, { repo: this.teamsRepo, populate: true }); this.repos.set(AuthorizableReferenceType.Submission, { repo: this.submissionRepo }); - this.repos.set(AuthorizableReferenceType.SchoolExternalTool, { repo: this.schoolExternalToolRepo }); + this.repos.set(AuthorizableReferenceType.SchoolExternalToolEntity, { repo: this.schoolExternalToolRepo }); this.repos.set(AuthorizableReferenceType.BoardNode, { repo: this.boardNodeAuthorizableService }); - this.repos.set(AuthorizableReferenceType.ContextExternalTool, { + this.repos.set(AuthorizableReferenceType.ContextExternalToolEntity, { repo: this.contextExternalToolAuthorizableService, }); } diff --git a/apps/server/src/modules/authorization/rule-manager.spec.ts b/apps/server/src/modules/authorization/rule-manager.spec.ts index f9ca0d54cb4..f7bd1edde5c 100644 --- a/apps/server/src/modules/authorization/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/rule-manager.spec.ts @@ -3,18 +3,18 @@ import { InternalServerErrorException, NotImplementedException } from '@nestjs/c import { Test } from '@nestjs/testing'; import { BoardDoRule, + ContextExternalToolRule, CourseGroupRule, CourseRule, LessonRule, SchoolExternalToolRule, SchoolRule, SubmissionRule, - TaskCardRule, TaskRule, TeamRule, UserRule, - ContextExternalToolRule, } from '@shared/domain/rules'; +import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; import { courseFactory, setupEntities, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from './authorization-context.builder'; import { RuleManager } from './rule-manager'; @@ -27,12 +27,12 @@ describe('RuleManager', () => { let schoolRule: DeepMocked; let userRule: DeepMocked; let taskRule: DeepMocked; - let taskCardRule: DeepMocked; let teamRule: DeepMocked; let submissionRule: DeepMocked; let schoolExternalToolRule: DeepMocked; let boardDoRule: DeepMocked; let contextExternalToolRule: DeepMocked; + let userLoginMigrationRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -46,12 +46,12 @@ describe('RuleManager', () => { { provide: SchoolRule, useValue: createMock() }, { provide: UserRule, useValue: createMock() }, { provide: TaskRule, useValue: createMock() }, - { provide: TaskCardRule, useValue: createMock() }, { provide: TeamRule, useValue: createMock() }, { provide: SubmissionRule, useValue: createMock() }, { provide: SchoolExternalToolRule, useValue: createMock() }, { provide: BoardDoRule, useValue: createMock() }, { provide: ContextExternalToolRule, useValue: createMock() }, + { provide: UserLoginMigrationRule, useValue: createMock() }, ], }).compile(); @@ -62,12 +62,12 @@ describe('RuleManager', () => { schoolRule = await module.get(SchoolRule); userRule = await module.get(UserRule); taskRule = await module.get(TaskRule); - taskCardRule = await module.get(TaskCardRule); teamRule = await module.get(TeamRule); submissionRule = await module.get(SubmissionRule); schoolExternalToolRule = await module.get(SchoolExternalToolRule); boardDoRule = await module.get(BoardDoRule); contextExternalToolRule = await module.get(ContextExternalToolRule); + userLoginMigrationRule = await module.get(UserLoginMigrationRule); }); afterEach(() => { @@ -92,12 +92,12 @@ describe('RuleManager', () => { schoolRule.isApplicable.mockReturnValueOnce(false); userRule.isApplicable.mockReturnValueOnce(false); taskRule.isApplicable.mockReturnValueOnce(false); - taskCardRule.isApplicable.mockReturnValueOnce(false); teamRule.isApplicable.mockReturnValueOnce(false); submissionRule.isApplicable.mockReturnValueOnce(false); schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); + userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -113,12 +113,12 @@ describe('RuleManager', () => { expect(schoolRule.isApplicable).toBeCalled(); expect(userRule.isApplicable).toBeCalled(); expect(taskRule.isApplicable).toBeCalled(); - expect(taskCardRule.isApplicable).toBeCalled(); expect(teamRule.isApplicable).toBeCalled(); expect(submissionRule.isApplicable).toBeCalled(); expect(schoolExternalToolRule.isApplicable).toBeCalled(); expect(boardDoRule.isApplicable).toBeCalled(); expect(contextExternalToolRule.isApplicable).toBeCalled(); + expect(userLoginMigrationRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -142,12 +142,12 @@ describe('RuleManager', () => { schoolRule.isApplicable.mockReturnValueOnce(false); userRule.isApplicable.mockReturnValueOnce(false); taskRule.isApplicable.mockReturnValueOnce(false); - taskCardRule.isApplicable.mockReturnValueOnce(false); teamRule.isApplicable.mockReturnValueOnce(false); submissionRule.isApplicable.mockReturnValueOnce(false); schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); + userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -171,12 +171,12 @@ describe('RuleManager', () => { schoolRule.isApplicable.mockReturnValueOnce(false); userRule.isApplicable.mockReturnValueOnce(false); taskRule.isApplicable.mockReturnValueOnce(false); - taskCardRule.isApplicable.mockReturnValueOnce(false); teamRule.isApplicable.mockReturnValueOnce(false); submissionRule.isApplicable.mockReturnValueOnce(false); schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); + userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/rule-manager.ts b/apps/server/src/modules/authorization/rule-manager.ts index 7e5226cc5c6..8d0fa8a629d 100644 --- a/apps/server/src/modules/authorization/rule-manager.ts +++ b/apps/server/src/modules/authorization/rule-manager.ts @@ -9,12 +9,12 @@ import { SchoolExternalToolRule, SchoolRule, SubmissionRule, - TaskCardRule, TaskRule, TeamRule, UserRule, } from '@shared/domain/rules'; import { ContextExternalToolRule } from '@shared/domain/rules/context-external-tool.rule'; +import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; import { AuthorizationContext, Rule } from './types'; @Injectable() @@ -27,20 +27,19 @@ export class RuleManager { private readonly lessonRule: LessonRule, private readonly schoolRule: SchoolRule, private readonly taskRule: TaskRule, - private readonly taskCardRule: TaskCardRule, private readonly userRule: UserRule, private readonly teamRule: TeamRule, private readonly submissionRule: SubmissionRule, private readonly schoolExternalToolRule: SchoolExternalToolRule, private readonly boardDoRule: BoardDoRule, - private readonly contextExternalToolRule: ContextExternalToolRule + private readonly contextExternalToolRule: ContextExternalToolRule, + private readonly userLoginMigrationRule: UserLoginMigrationRule ) { this.rules = [ this.courseRule, this.courseGroupRule, this.lessonRule, this.taskRule, - this.taskCardRule, this.teamRule, this.userRule, this.schoolRule, @@ -48,6 +47,7 @@ export class RuleManager { this.schoolExternalToolRule, this.boardDoRule, this.contextExternalToolRule, + this.userLoginMigrationRule, ]; } diff --git a/apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts b/apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts index 93d18d374a0..f36ca235af1 100644 --- a/apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts +++ b/apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts @@ -7,7 +7,7 @@ export enum AuthorizableReferenceType { 'Lesson' = 'lessons', 'Team' = 'teams', 'Submission' = 'submissions', - 'SchoolExternalTool' = 'school_external_tools', + 'SchoolExternalToolEntity' = 'school_external_tools', 'BoardNode' = 'boardnodes', - 'ContextExternalTool' = 'context_external_tools', + 'ContextExternalToolEntity' = 'context_external_tools', } diff --git a/apps/server/src/modules/authorization/types/authorization-loader-service.ts b/apps/server/src/modules/authorization/types/authorization-loader-service.ts index 7a6616591b7..b15f0059cac 100644 --- a/apps/server/src/modules/authorization/types/authorization-loader-service.ts +++ b/apps/server/src/modules/authorization/types/authorization-loader-service.ts @@ -4,3 +4,8 @@ import { AuthorizableObject } from '@shared/domain/domain-object'; // fix import export interface AuthorizationLoaderService { findById(id: EntityId): Promise; } + +export interface AuthorizationLoaderServiceGeneric + extends AuthorizationLoaderService { + findById(id: EntityId): Promise; +} diff --git a/apps/server/src/modules/board/board-api.module.ts b/apps/server/src/modules/board/board-api.module.ts index 1c97de9c31c..2ac4581dfd0 100644 --- a/apps/server/src/modules/board/board-api.module.ts +++ b/apps/server/src/modules/board/board-api.module.ts @@ -2,13 +2,20 @@ import { forwardRef, Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@src/modules/authorization'; import { BoardModule } from './board.module'; -import { BoardController, CardController, ColumnController, ElementController } from './controller'; +import { + BoardController, + BoardSubmissionController, + CardController, + ColumnController, + ElementController, +} from './controller'; import { BoardUc, CardUc } from './uc'; import { ElementUc } from './uc/element.uc'; +import { SubmissionItemUc } from './uc/submission-item.uc'; @Module({ imports: [BoardModule, LoggerModule, forwardRef(() => AuthorizationModule)], - controllers: [BoardController, ColumnController, CardController, ElementController], - providers: [BoardUc, CardUc, ElementUc], + controllers: [BoardController, ColumnController, CardController, ElementController, BoardSubmissionController], + providers: [BoardUc, CardUc, ElementUc, SubmissionItemUc], }) export class BoardApiModule {} diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index e46e364a225..722ba933dc1 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -13,12 +13,13 @@ import { ColumnBoardService, ColumnService, ContentElementService, + SubmissionItemService, } from './service'; -import { SubmissionItemService } from './service/submission-item.service'; @Module({ imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule], providers: [ + BoardDoAuthorizableService, BoardDoRepo, BoardDoService, BoardNodeRepo, @@ -26,18 +27,17 @@ import { SubmissionItemService } from './service/submission-item.service'; ColumnBoardService, ColumnService, ContentElementService, - SubmissionItemService, - RecursiveDeleteVisitor, ContentElementFactory, - BoardDoAuthorizableService, CourseRepo, // TODO: import learnroom module instead. This is currently not possible due to dependency cycle with authorisation service + RecursiveDeleteVisitor, + SubmissionItemService, ], exports: [ + BoardDoAuthorizableService, + CardService, ColumnBoardService, ColumnService, - CardService, ContentElementService, - BoardDoAuthorizableService, SubmissionItemService, ], }) diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-create.api.spec.ts index 537e90af3aa..3e41ee48be0 100644 --- a/apps/server/src/modules/board/controller/api-test/submission-item-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/submission-item-create.api.spec.ts @@ -1,8 +1,10 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardExternalReferenceType } from '@shared/domain'; +import { BoardExternalReferenceType, SubmissionItemNode } from '@shared/domain'; import { + TestApiClient, + UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, @@ -10,8 +12,6 @@ import { courseFactory, submissionContainerElementNodeFactory, userFactory, - TestApiClient, - UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; import { SubmissionItemResponse } from '../dto'; @@ -37,7 +37,7 @@ describe('submission create (api)', () => { await app.close(); }); - describe('with valid teacher user', () => { + describe('when user is a valid teacher', () => { const setup = async () => { await cleanupCollections(em); @@ -62,6 +62,40 @@ describe('submission create (api)', () => { return { loggedInClient, teacherUser, columnBoardNode, columnNode, cardNode, submissionContainerNode }; }; + it('should return status 403', async () => { + const { loggedInClient, submissionContainerNode } = await setup(); + + const response = await loggedInClient.post(`${submissionContainerNode.id}/submissions`, { completed: false }); + + expect(response.status).toEqual(403); + }); + }); + + describe('when user is a student who is part of course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentAccount, studentUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser, columnBoardNode, columnNode, cardNode, submissionContainerNode }; + }; it('should return status 201', async () => { const { loggedInClient, submissionContainerNode } = await setup(); @@ -71,16 +105,36 @@ describe('submission create (api)', () => { }); it('should return created submission', async () => { - const { loggedInClient, teacherUser, submissionContainerNode } = await setup(); + const { loggedInClient, studentUser, submissionContainerNode } = await setup(); - const response = await loggedInClient.post(`${submissionContainerNode.id}/submissions`, { completed: false }); + const response = await loggedInClient.post(`${submissionContainerNode.id}/submissions`, { completed: true }); const result = response.body as SubmissionItemResponse; - expect(result.completed).toBe(false); + expect(result.completed).toBe(true); expect(result.id).toBeDefined(); expect(result.timestamps.createdAt).toBeDefined(); expect(result.timestamps.lastUpdatedAt).toBeDefined(); - expect(result.userId).toBe(teacherUser.id); + expect(result.userData.userId).toBe(studentUser.id); + expect(result.userData.firstName).toBe('John'); + expect(result.userData.lastName).toBe('Mr Doe'); + }); + + it('should actually create the submission item', async () => { + const { loggedInClient, submissionContainerNode } = await setup(); + const response = await loggedInClient.post(`${submissionContainerNode.id}/submissions`, { completed: true }); + + const submissionItemResponse = response.body as SubmissionItemResponse; + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemResponse.id); + expect(result.id).toEqual(submissionItemResponse.id); + expect(result.completed).toEqual(true); + }); + + it('should fail without params completed', async () => { + const { loggedInClient, submissionContainerNode } = await setup(); + + const response = await loggedInClient.post(`${submissionContainerNode.id}/submissions`, {}); + expect(response.status).toBe(400); }); it('should fail when user wants to create more than one submission-item', async () => { @@ -94,12 +148,12 @@ describe('submission create (api)', () => { }); }); - describe('with valid student user', () => { + describe('when user is an student who is not part of course', () => { const setup = async () => { await cleanupCollections(em); const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - const course = courseFactory.build({ students: [studentUser] }); + const course = courseFactory.build({ students: [] }); await em.persistAndFlush([studentAccount, studentUser, course]); const columnBoardNode = columnBoardNodeFactory.buildWithId({ @@ -129,7 +183,7 @@ describe('submission create (api)', () => { }); }); - describe('with invalid user', () => { + describe('when with invalid user', () => { const setup = async () => { await cleanupCollections(em); diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts new file mode 100644 index 00000000000..e32e04914f3 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts @@ -0,0 +1,243 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain'; +import { + TestApiClient, + UserAndAccountTestFactory, + cardNodeFactory, + cleanupCollections, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, + submissionContainerElementNodeFactory, + submissionItemNodeFactory, + userFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { SubmissionItemResponse } from '../dto'; + +const baseRouteName = '/board-submissions'; +describe('submission item lookup (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('with teacher of two submission containers filled with submission items of 2 students', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentAccount: studentAccount1, studentUser: studentUser1 } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ teachers: [teacherUser], students: [studentUser1, studentUser2] }); + await em.persistAndFlush([ + studentAccount1, + studentUser1, + studentAccount2, + studentUser2, + teacherAccount, + teacherUser, + course, + ]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode1 = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + const submissionContainerNode2 = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + const item11 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode1, + userId: studentUser1.id, + }); + const item12 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode1, + userId: studentUser2.id, + }); + const item21 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode2, + userId: studentUser1.id, + }); + const item22 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode2, + userId: studentUser2.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + submissionContainerNode1, + submissionContainerNode2, + item11, + item12, + item21, + item22, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + teacherUser, + columnBoardNode, + columnNode, + cardNode, + submissionContainerNode1, + submissionContainerNode2, + item11, + item12, + item21, + item22, + }; + }; + it('should return status 200', async () => { + const { loggedInClient, submissionContainerNode1 } = await setup(); + + const response = await loggedInClient.get(`${submissionContainerNode1.id}`); + expect(response.status).toEqual(200); + }); + + it('should return all items from container 1 as teacher', async () => { + const { loggedInClient, submissionContainerNode1, item11, item12 } = await setup(); + + const response = await loggedInClient.get(`${submissionContainerNode1.id}`); + const body = response.body as SubmissionItemResponse[]; + expect(body.length).toBe(2); + expect(body.map((item) => item.id)).toContain(item11.id); + expect(body.map((item) => item.id)).toContain(item12.id); + }); + + it('should return all items from container 2 as teacher', async () => { + const { loggedInClient, submissionContainerNode2, item21, item22 } = await setup(); + + const response = await loggedInClient.get(`${submissionContainerNode2.id}`); + const body = response.body as SubmissionItemResponse[]; + expect(body.length).toBe(2); + expect(body.map((item) => item.id)).toContain(item21.id); + expect(body.map((item) => item.id)).toContain(item22.id); + }); + }); + + describe('with student of a submission container filled with 2 items', () => { + const setup = async () => { + await cleanupCollections(em); + + // const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentAccount: studentAccount1, studentUser: studentUser1 } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ teachers: [], students: [studentUser1, studentUser2] }); + await em.persistAndFlush([studentAccount1, studentUser1, studentAccount2, studentUser2, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + const item1 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode, + userId: studentUser1.id, + }); + const item2 = submissionItemNodeFactory.buildWithId({ + parent: submissionContainerNode, + userId: studentUser2.id, + }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, item1, item2]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount1); + + return { + loggedInClient, + columnBoardNode, + columnNode, + cardNode, + submissionContainerNode, + item1, + item2, + }; + }; + it('should return status 200', async () => { + const { loggedInClient, submissionContainerNode } = await setup(); + + const response = await loggedInClient.get(`${submissionContainerNode.id}`); + expect(response.status).toEqual(200); + }); + + it('should return only submission item of student 1', async () => { + const { loggedInClient, submissionContainerNode, item1 } = await setup(); + + const response = await loggedInClient.get(`${submissionContainerNode.id}`); + const body = response.body as SubmissionItemResponse[]; + expect(body.length).toBe(1); + expect(body[0].id).toBe(item1.id); + }); + }); + + describe('with invalid user', () => { + const setup = async () => { + await cleanupCollections(em); + + const user = userFactory.build(); + const course = courseFactory.build({ teachers: [user] }); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([user, teacherAccount, teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, columnBoardNode, columnNode, cardNode, submissionContainerNode }; + }; + + it('should return 403', async () => { + const { loggedInClient, submissionContainerNode } = await setup(); + + const invalidUser = userFactory.build(); + await em.persistAndFlush([invalidUser]); + + const response = await loggedInClient.get(`${submissionContainerNode.id}`); + + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-update.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-update.api.spec.ts new file mode 100644 index 00000000000..dfa8bf017b3 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/submission-item-update.api.spec.ts @@ -0,0 +1,243 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType, SubmissionItemNode } from '@shared/domain'; +import { + TestApiClient, + UserAndAccountTestFactory, + cardNodeFactory, + cleanupCollections, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, + submissionContainerElementNodeFactory, + submissionItemNodeFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { SubmissionItemResponse } from '../dto'; + +const baseRouteName = '/board-submissions'; +describe('submission item update (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when user is a valid teacher', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: 'foo', + parent: submissionContainerNode, + completed: true, + }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, submissionItemNode }; + }; + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + expect(response.status).toEqual(403); + }); + it('should not actually update submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); + + describe('when user is a student trying to update his own submission item', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentAccount, studentUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser, submissionItemNode }; + }; + it('should return status 204', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + expect(response.status).toEqual(204); + }); + + it('should actually update the submission item', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + const response = await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + const submissionItemResponse = response.body as SubmissionItemResponse; + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemResponse.id); + expect(result.id).toEqual(submissionItemNode.id); + expect(result.completed).toEqual(false); + }); + + it('should fail without params completed', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.patch(`${submissionItemNode.id}`, {}); + expect(response.status).toBe(400); + }); + }); + + describe('when user is a student from same course, and tries to update a submission item he did not create himself', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser, studentUser2] }); + await em.persistAndFlush([studentAccount, studentUser, studentAccount2, studentUser2, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount2); + + return { loggedInClient, submissionItemNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + expect(response.status).toEqual(403); + }); + it('should not actually update submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); + + describe('when user is a student not in course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentAccount, studentUser, studentAccount2, studentUser2, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount2); + + return { loggedInClient, submissionItemNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + expect(response.status).toEqual(403); + }); + + it('should not actually update submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.patch(`${submissionItemNode.id}`, { completed: false }); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/board-submission.controller.ts b/apps/server/src/modules/board/controller/board-submission.controller.ts new file mode 100644 index 00000000000..8f74b05df3f --- /dev/null +++ b/apps/server/src/modules/board/controller/board-submission.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, ForbiddenException, Get, HttpCode, NotFoundException, Param, Patch } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import { ICurrentUser } from '@src/modules/authentication'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { CardUc } from '../uc'; +import { ElementUc } from '../uc/element.uc'; +import { SubmissionItemUc } from '../uc/submission-item.uc'; +import { + SubmissionContainerUrlParams, + SubmissionItemResponse, + SubmissionItemUrlParams, + UpdateSubmissionItemBodyParams, +} from './dto'; +import { SubmissionItemResponseMapper } from './mapper'; + +@ApiTags('Board Submission') +@Authenticate('jwt') +@Controller('board-submissions') +export class BoardSubmissionController { + constructor( + private readonly cardUc: CardUc, + private readonly elementUc: ElementUc, + private readonly submissionItemUc: SubmissionItemUc + ) {} + + @ApiOperation({ summary: 'Get a list of submission items by their parent container.' }) + @ApiResponse({ status: 200, type: [SubmissionItemResponse] }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @Get(':submissionContainerId') + async getSubmissionItems( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: SubmissionContainerUrlParams + ): Promise { + const items = await this.submissionItemUc.findSubmissionItems(currentUser.userId, urlParams.submissionContainerId); + const mapper = SubmissionItemResponseMapper.getInstance(); + return items.map((item) => mapper.mapToResponse(item)); + } + + @ApiOperation({ summary: 'Update a single submission item.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Patch(':submissionItemId') + async updateSubmissionItem( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: SubmissionItemUrlParams, + @Body() bodyParams: UpdateSubmissionItemBodyParams + ) { + await this.submissionItemUc.updateSubmissionItem( + currentUser.userId, + urlParams.submissionItemId, + bodyParams.completed + ); + } +} diff --git a/apps/server/src/modules/board/controller/dto/index.ts b/apps/server/src/modules/board/controller/dto/index.ts index fe7b031a7c4..0fcd862a516 100644 --- a/apps/server/src/modules/board/controller/dto/index.ts +++ b/apps/server/src/modules/board/controller/dto/index.ts @@ -4,3 +4,4 @@ export * from './card.url.params'; export * from './element'; export * from './submission-item'; export * from './timestamps.response'; +export * from './user-data.response'; diff --git a/apps/server/src/modules/board/controller/dto/submission-item/index.ts b/apps/server/src/modules/board/controller/dto/submission-item/index.ts index 5d03d6a2d72..200cdc81ea0 100644 --- a/apps/server/src/modules/board/controller/dto/submission-item/index.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/index.ts @@ -1,2 +1,5 @@ +export * from './submission-container.url.params'; export * from './create-submission-item.body.params'; export * from './submission-item.response'; +export * from './submission-item.url.params'; +export * from './update-submission-item.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/submission-item/submission-container.url.params.ts b/apps/server/src/modules/board/controller/dto/submission-item/submission-container.url.params.ts new file mode 100644 index 00000000000..d4ecf54ba08 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/submission-item/submission-container.url.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class SubmissionContainerUrlParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the submission container.', + required: true, + nullable: false, + }) + submissionContainerId!: string; +} diff --git a/apps/server/src/modules/board/controller/dto/submission-item/submission-item.response.ts b/apps/server/src/modules/board/controller/dto/submission-item/submission-item.response.ts index c375184e327..02c3936d843 100644 --- a/apps/server/src/modules/board/controller/dto/submission-item/submission-item.response.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/submission-item.response.ts @@ -1,12 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { TimestampsResponse } from '../timestamps.response'; +import { UserDataResponse } from '../user-data.response'; export class SubmissionItemResponse { - constructor({ id, timestamps, completed, userId }: SubmissionItemResponse) { + constructor({ id, timestamps, completed, userData }: SubmissionItemResponse) { this.id = id; this.timestamps = timestamps; this.completed = completed; - this.userId = userId; + this.userData = userData; } @ApiProperty({ pattern: '[a-f0-9]{24}' }) @@ -19,5 +20,5 @@ export class SubmissionItemResponse { completed: boolean; @ApiProperty() - userId: string; + userData: UserDataResponse; } diff --git a/apps/server/src/modules/task-card/controller/dto/task-card.url.params.ts b/apps/server/src/modules/board/controller/dto/submission-item/submission-item.url.params.ts similarity index 58% rename from apps/server/src/modules/task-card/controller/dto/task-card.url.params.ts rename to apps/server/src/modules/board/controller/dto/submission-item/submission-item.url.params.ts index 6b4abab3efb..9d5da3d2c7e 100644 --- a/apps/server/src/modules/task-card/controller/dto/task-card.url.params.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/submission-item.url.params.ts @@ -1,12 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId } from 'class-validator'; -export class TaskCardUrlParams { +export class SubmissionItemUrlParams { @IsMongoId() @ApiProperty({ - description: 'The id of the task card.', + description: 'The id of the submission item.', required: true, nullable: false, }) - id!: string; + submissionItemId!: string; } diff --git a/apps/server/src/modules/board/controller/dto/submission-item/update-submission-item.body.params.ts b/apps/server/src/modules/board/controller/dto/submission-item/update-submission-item.body.params.ts new file mode 100644 index 00000000000..9ef8a189ea3 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/submission-item/update-submission-item.body.params.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class UpdateSubmissionItemBodyParams { + @IsBoolean() + @ApiProperty({ + description: 'Boolean indicating whether the submission is completed.', + required: true, + }) + completed!: boolean; +} diff --git a/apps/server/src/modules/board/controller/dto/user-data.response.ts b/apps/server/src/modules/board/controller/dto/user-data.response.ts new file mode 100644 index 00000000000..78b71d0de8a --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/user-data.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserDataResponse { + constructor({ userId, firstName, lastName }: UserDataResponse) { + this.userId = userId; + this.firstName = firstName; + this.lastName = lastName; + } + + @ApiProperty() + firstName!: string; + + @ApiProperty() + lastName!: string; + + @ApiProperty() + userId!: string; +} diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 19cf2f2449b..4048042f9cc 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -86,7 +86,7 @@ export class ElementController { await this.cardUc.deleteElement(currentUser.userId, urlParams.contentElementId); } - @ApiOperation({ summary: 'Create a new submission item in a submission container element.' }) + @ApiOperation({ summary: 'Create a new submission item having parent a submission container element.' }) @ApiExtraModels(SubmissionItemResponse) @ApiResponse({ status: 201, type: SubmissionItemResponse }) @ApiResponse({ status: 400, type: ApiValidationError }) @@ -94,16 +94,18 @@ export class ElementController { @ApiResponse({ status: 404, type: NotFoundException }) @ApiBody({ required: true, type: CreateSubmissionItemBodyParams }) @Post(':contentElementId/submissions') - async createSubmission( + async createSubmissionItem( @Param() urlParams: ContentElementUrlParams, @Body() bodyParams: CreateSubmissionItemBodyParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - const submission = await this.elementUc.createSubmissionItem(currentUser.userId, urlParams.contentElementId); - submission.userId = currentUser.userId; - submission.completed = false; + const submissionItem = await this.elementUc.createSubmissionItem( + currentUser.userId, + urlParams.contentElementId, + bodyParams.completed + ); const mapper = SubmissionItemResponseMapper.getInstance(); - const response = mapper.mapToResponse(submission); + const response = mapper.mapToResponse(submissionItem); return response; } diff --git a/apps/server/src/modules/board/controller/index.ts b/apps/server/src/modules/board/controller/index.ts index a3b7c985f70..185949304a2 100644 --- a/apps/server/src/modules/board/controller/index.ts +++ b/apps/server/src/modules/board/controller/index.ts @@ -1,4 +1,5 @@ +export * from './board-submission.controller'; export * from './board.controller'; +export * from './card.controller'; export * from './column.controller'; export * from './element.controller'; -export * from './card.controller'; diff --git a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts index d76ab08256a..c2c613da8c6 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts @@ -1,5 +1,5 @@ import { SubmissionItem } from '@shared/domain'; -import { SubmissionItemResponse, TimestampsResponse } from '../dto'; +import { SubmissionItemResponse, TimestampsResponse, UserDataResponse } from '../dto'; export class SubmissionItemResponseMapper { private static instance: SubmissionItemResponseMapper; @@ -12,12 +12,20 @@ export class SubmissionItemResponseMapper { return SubmissionItemResponseMapper.instance; } - public mapToResponse(submission: SubmissionItem): SubmissionItemResponse { + public mapToResponse(submissionItem: SubmissionItem): SubmissionItemResponse { const result = new SubmissionItemResponse({ - id: submission.id, - timestamps: new TimestampsResponse({ lastUpdatedAt: submission.updatedAt, createdAt: submission.createdAt }), - completed: submission.completed, - userId: submission.userId, + completed: submissionItem.completed, + id: submissionItem.id, + timestamps: new TimestampsResponse({ + lastUpdatedAt: submissionItem.updatedAt, + createdAt: submissionItem.createdAt, + }), + userData: new UserDataResponse({ + // TODO: put valid user info here which comes from the submission owner + firstName: 'John', + lastName: 'Mr Doe', + userId: submissionItem.userId, + }), }); return result; 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 0a468b6012f..446d8b8cfa3 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 @@ -3,7 +3,15 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AnyBoardDo, Card, CardNode, Column, ColumnBoard, RichTextElementNode } from '@shared/domain'; +import { + AnyBoardDo, + BoardExternalReferenceType, + Card, + CardNode, + Column, + ColumnBoard, + RichTextElementNode, +} from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { cardFactory, @@ -13,6 +21,7 @@ import { columnBoardNodeFactory, columnFactory, columnNodeFactory, + courseFactory, fileElementFactory, richTextElementFactory, richTextElementNodeFactory, @@ -141,6 +150,78 @@ describe(BoardDoRepo.name, () => { }); }); + describe('getTitlesByIds', () => { + const setup = async () => { + const cardsWithTitles = cardNodeFactory.buildList(3); + const cardWithoutTitle = cardNodeFactory.build({ title: undefined }); + + await em.persistAndFlush([...cardsWithTitles, cardWithoutTitle]); + + return { cardsWithTitles, cardWithoutTitle }; + }; + + it('should return titles of node for list of ids', async () => { + const { cardsWithTitles } = await setup(); + + const titleMap = await repo.getTitlesByIds(cardsWithTitles.map((card) => card.id)); + + cardsWithTitles.forEach((card) => { + expect(titleMap[card.id]).toEqual(card.title); + }); + }); + + it('should return node of card for single id', async () => { + const { cardsWithTitles } = await setup(); + + const titleMap = await repo.getTitlesByIds(cardsWithTitles[0].id); + + expect(titleMap[cardsWithTitles[0].id]).toEqual(cardsWithTitles[0].title); + }); + + it('should handle node without title', async () => { + const { cardWithoutTitle } = await setup(); + + const titleMap = await repo.getTitlesByIds(cardWithoutTitle.id); + + expect(titleMap[cardWithoutTitle.id]).toEqual(''); + }); + + it('should not return title of node that has not been asked about', async () => { + const { cardsWithTitles } = await setup(); + + const titleMap = await repo.getTitlesByIds(cardsWithTitles[0].id); + + expect(titleMap[cardsWithTitles[1].id]).toEqual(undefined); + }); + }); + + describe('findIdsByExternalReference', () => { + const setup = async () => { + const course = courseFactory.build(); + await em.persistAndFlush(course); + const boardNode = columnBoardNodeFactory.build({ + context: { + type: BoardExternalReferenceType.Course, + id: course.id, + }, + }); + await em.persistAndFlush(boardNode); + + return { boardNode, course }; + }; + + it('should find courseboard by course', async () => { + const { course, boardNode } = await setup(); + + const ids = await repo.findIdsByExternalReference({ + type: BoardExternalReferenceType.Course, + id: course.id, + }); + + expect(ids[0]).toEqual(boardNode.id); + }); + }); + describe('findParentOfId', () => { describe('when fetching a parent', () => { const setup = async () => { diff --git a/apps/server/src/modules/board/repo/board-do.repo.ts b/apps/server/src/modules/board/repo/board-do.repo.ts index 94ae8bf353c..a9358bc1177 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.ts @@ -50,7 +50,7 @@ export class BoardDoRepo { return domainObjects; } - async getTitleById(id: EntityId[] | EntityId): Promise> { + async getTitlesByIds(id: EntityId[] | EntityId): Promise> { const ids = Utils.asArray(id); const boardNodes = await this.em.find(BoardNode, { id: { $in: ids } }); diff --git a/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts b/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts index 315f576d765..90fb5ebf243 100644 --- a/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardExternalReferenceType, BoardRoles } from '@shared/domain'; +import { BoardExternalReferenceType, BoardRoles, UserRoleEnum } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; import { courseFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; import { columnBoardFactory, columnFactory } from '@shared/testing/factory/domainobject'; @@ -114,12 +114,22 @@ describe(BoardDoAuthorizableService.name, () => { return map; }, {}); + const userRoleEnums = boardDoAuthorizable.users.reduce((map, user) => { + map[user.userId] = user.userRoleEnum; + return map; + }, {}); + expect(boardDoAuthorizable.users).toHaveLength(5); expect(userPermissions[teacherId]).toEqual([BoardRoles.EDITOR]); + expect(userRoleEnums[teacherId]).toEqual(UserRoleEnum.TEACHER); expect(userPermissions[substitutionTeacherId]).toEqual([BoardRoles.EDITOR]); + expect(userRoleEnums[substitutionTeacherId]).toEqual(UserRoleEnum.SUBSTITUTION_TEACHER); expect(userPermissions[studentIds[0]]).toEqual([BoardRoles.READER]); + expect(userRoleEnums[studentIds[0]]).toEqual(UserRoleEnum.STUDENT); expect(userPermissions[studentIds[1]]).toEqual([BoardRoles.READER]); + expect(userRoleEnums[studentIds[1]]).toEqual(UserRoleEnum.STUDENT); expect(userPermissions[studentIds[2]]).toEqual([BoardRoles.READER]); + expect(userRoleEnums[studentIds[2]]).toEqual(UserRoleEnum.STUDENT); }); }); diff --git a/apps/server/src/modules/board/service/board-do-authorizable.service.ts b/apps/server/src/modules/board/service/board-do-authorizable.service.ts index 07d57b69460..bc6c5c219e3 100644 --- a/apps/server/src/modules/board/service/board-do-authorizable.service.ts +++ b/apps/server/src/modules/board/service/board-do-authorizable.service.ts @@ -8,6 +8,7 @@ import { Course, EntityId, UserBoardRoles, + UserRoleEnum, } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; import { AuthorizationLoaderService } from '@src/modules/authorization'; @@ -49,13 +50,13 @@ export class BoardDoAuthorizableService implements AuthorizationLoaderService { private mapCourseUsersToUsergroup(course: Course): UserBoardRoles[] { const users = [ ...course.getTeacherIds().map((userId) => { - return { userId, roles: [BoardRoles.EDITOR] }; + return { userId, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }; }), ...course.getSubstitutionTeacherIds().map((userId) => { - return { userId, roles: [BoardRoles.EDITOR] }; + return { userId, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.SUBSTITUTION_TEACHER }; }), ...course.getStudentIds().map((userId) => { - return { userId, roles: [BoardRoles.READER] }; + return { userId, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }; }), ]; return users; diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index e8d329f8286..6ed6bb6f0a4 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -120,7 +120,7 @@ describe(ColumnBoardService.name, () => { await service.getBoardObjectTitlesById(ids); - expect(boardDoRepo.getTitleById).toHaveBeenCalledWith(ids); + expect(boardDoRepo.getTitlesByIds).toHaveBeenCalledWith(ids); }); }); }); @@ -237,5 +237,38 @@ describe(ColumnBoardService.name, () => { ); }); }); + + describe('contact link text element', () => { + it('should add a text element containing the link url when theme is not default', async () => { + Configuration.set('SC_THEME', 'brb'); + const { externalReference } = setup(); + + const clientUrl = Configuration.get('HOST') as string; + const expectedContactUrl = `${clientUrl}/help/contact/`; + + const columnBoard = await service.createWelcomeColumnBoard(externalReference); + + const column = columnBoard.children[0]; + const card = column.children[0] as Card; + const element = card.children.find((child) => (child as RichTextElement).text.includes(clientUrl)); + + expect((element as RichTextElement).text).toEqual(expect.stringContaining(expectedContactUrl)); + }); + + it('should not add a text element when theme is default', async () => { + Configuration.set('SC_THEME', 'default'); + const { externalReference } = setup(); + + const clientUrl = Configuration.get('HOST') as string; + + const columnBoard = await service.createWelcomeColumnBoard(externalReference); + + const column = columnBoard.children[0]; + const card = column.children[0] as Card; + const element = card.children.find((child) => (child as RichTextElement).text.includes(clientUrl)); + + expect(element).toBeUndefined(); + }); + }); }); }); diff --git a/apps/server/src/modules/board/service/column-board.service.ts b/apps/server/src/modules/board/service/column-board.service.ts index b0d4a21c49c..f53f4f5f051 100644 --- a/apps/server/src/modules/board/service/column-board.service.ts +++ b/apps/server/src/modules/board/service/column-board.service.ts @@ -35,7 +35,7 @@ export class ColumnBoardService { } async getBoardObjectTitlesById(boardIds: EntityId[]): Promise> { - const titleMap = this.boardDoRepo.getTitleById(boardIds); + const titleMap = this.boardDoRepo.getTitlesByIds(boardIds); return titleMap; } @@ -113,6 +113,15 @@ export class ColumnBoardService { card.addChild(text3); } + const SC_THEME = Configuration.get('SC_THEME') as string; + if (SC_THEME !== 'default') { + const clientUrl = Configuration.get('HOST') as string; + const text4 = this.createRichTextElement( + `

Wir freuen uns über Feedback und Wünsche.

` + ); + card.addChild(text4); + } + await this.boardDoRepo.save(columnBoard); return columnBoard; diff --git a/apps/server/src/modules/board/service/index.ts b/apps/server/src/modules/board/service/index.ts index edf579d31b6..8ff2787f35d 100644 --- a/apps/server/src/modules/board/service/index.ts +++ b/apps/server/src/modules/board/service/index.ts @@ -4,3 +4,4 @@ export * from './card.service'; export * from './column-board.service'; export * from './column.service'; export * from './content-element.service'; +export * from './submission-item.service'; diff --git a/apps/server/src/modules/board/service/submission-item.service.spec.ts b/apps/server/src/modules/board/service/submission-item.service.spec.ts index bff4ca0139f..0dd2c9efbf9 100644 --- a/apps/server/src/modules/board/service/submission-item.service.spec.ts +++ b/apps/server/src/modules/board/service/submission-item.service.spec.ts @@ -2,8 +2,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SubmissionItem } from '@shared/domain'; -import { setupEntities } from '@shared/testing'; -import { cardFactory, submissionItemFactory } from '@shared/testing/factory/domainobject'; +import { ValidationError } from '@shared/common'; +import { setupEntities, userFactory } from '@shared/testing'; +import { + cardFactory, + submissionContainerElementFactory, + submissionItemFactory, +} from '@shared/testing/factory/domainobject'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { SubmissionItemService } from './submission-item.service'; @@ -38,6 +43,27 @@ describe(SubmissionItemService.name, () => { await module.close(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('create', () => { + describe('when calling the create method', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionContainer = submissionContainerElementFactory.build(); + + return { submissionContainer, user }; + }; + + it('should return an instance of SubmissionItem', async () => { + const { submissionContainer, user } = setup(); + const result = await service.create(user.id, submissionContainer, { completed: true }); + expect(result).toBeInstanceOf(SubmissionItem); + }); + }); + }); + describe('findById', () => { describe('when trying get SubmissionItem by id', () => { const setup = () => { @@ -71,4 +97,49 @@ describe(SubmissionItemService.name, () => { }); }); }); + + describe('update', () => { + const setup = () => { + const submissionContainer = submissionContainerElementFactory.build(); + const submissionItem = submissionItemFactory.build(); + + boardDoRepo.findParentOfId.mockResolvedValueOnce(submissionContainer); + + return { submissionContainer, submissionItem }; + }; + + it('should fetch the SubmissionContainer parent', async () => { + const { submissionItem } = setup(); + + await service.update(submissionItem, true); + + expect(boardDoRepo.findParentOfId).toHaveBeenCalledWith(submissionItem.id); + }); + + it('should call bord repo to save submission item', async () => { + const { submissionItem, submissionContainer } = setup(); + + await service.update(submissionItem, true); + + expect(boardDoRepo.save).toHaveBeenCalledWith(submissionItem, submissionContainer); + }); + + it('should save completion', async () => { + const { submissionItem, submissionContainer } = setup(); + + await service.update(submissionItem, false); + + expect(boardDoRepo.save).toHaveBeenCalledWith(expect.objectContaining({ completed: false }), submissionContainer); + }); + + it('should throw if parent SubmissionContainer dueDate is in the past', async () => { + const { submissionItem, submissionContainer } = setup(); + + const yesterday = new Date(Date.now() - 86400000); + submissionContainer.dueDate = yesterday; + boardDoRepo.findParentOfId.mockResolvedValue(submissionContainer); + + await expect(service.update(submissionItem, true)).rejects.toThrowError(ValidationError); + }); + }); }); diff --git a/apps/server/src/modules/board/service/submission-item.service.ts b/apps/server/src/modules/board/service/submission-item.service.ts index 4c765202fe6..990504357a7 100644 --- a/apps/server/src/modules/board/service/submission-item.service.ts +++ b/apps/server/src/modules/board/service/submission-item.service.ts @@ -1,6 +1,9 @@ +import { ObjectId } from 'bson'; import { Injectable, NotFoundException } from '@nestjs/common'; + import { EntityId, SubmissionContainerElement, SubmissionItem } from '@shared/domain'; -import { ObjectId } from 'bson'; +import { ValidationError } from '@shared/common'; + import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; @@ -37,4 +40,15 @@ export class SubmissionItemService { return submissionItem; } + + async update(submissionItem: SubmissionItem, completed: boolean): Promise { + const parent = (await this.boardDoRepo.findParentOfId(submissionItem.id)) as SubmissionContainerElement; + const now = new Date(); + if (parent.dueDate && parent.dueDate < now) { + throw new ValidationError('not allowed to save anymore'); + } + submissionItem.completed = completed; + + await this.boardDoRepo.save(submissionItem, parent); + } } diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index 5ba8e1d3ef8..efd41e49ce6 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain/domainobject/board'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain/domainobject/board'; import { setupEntities, userFactory } from '@shared/testing'; import { cardFactory, columnBoardFactory, columnFactory } from '@shared/testing/factory/domainobject'; import { LegacyLogger } from '@src/core/logger'; @@ -85,7 +85,7 @@ describe(BoardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], id: board.id, }); const createCardBodyParams = { diff --git a/apps/server/src/modules/board/uc/card.uc.spec.ts b/apps/server/src/modules/board/uc/card.uc.spec.ts index 6ccc2b8f284..130a7feac40 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain'; import { setupEntities, userFactory } from '@shared/testing'; import { cardFactory, richTextElementFactory } from '@shared/testing/factory/domainobject'; import { LegacyLogger } from '@src/core/logger'; @@ -195,7 +195,7 @@ describe(CardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], id: element.id, }); diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index 44835b0be2b..af0da416a23 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -137,7 +137,7 @@ describe(ElementUc.name, () => { it('should throw', async () => { const { fileElement, user } = setup(); - await expect(uc.createSubmissionItem(user.id, fileElement.id)).rejects.toThrowError( + await expect(uc.createSubmissionItem(user.id, fileElement.id, true)).rejects.toThrowError( 'Cannot create submission-item for non submission-container-element' ); }); @@ -158,7 +158,7 @@ describe(ElementUc.name, () => { it('should throw', async () => { const { submissionContainer, user } = setup(); - await expect(uc.createSubmissionItem(user.id, submissionContainer.id)).rejects.toThrowError( + await expect(uc.createSubmissionItem(user.id, submissionContainer.id, true)).rejects.toThrowError( 'Children of submission-container-element must be of type submission-item' ); }); @@ -179,7 +179,7 @@ describe(ElementUc.name, () => { it('should throw', async () => { const { submissionContainer, user } = setup(); - await expect(uc.createSubmissionItem(user.id, submissionContainer.id)).rejects.toThrowError( + await expect(uc.createSubmissionItem(user.id, submissionContainer.id, true)).rejects.toThrowError( 'User is not allowed to have multiple submission-items per submission-container-element' ); }); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index 0eb8229d72b..708599d1f65 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -1,5 +1,5 @@ import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { AnyBoardDo, EntityId, SubmissionContainerElement, SubmissionItem } from '@shared/domain'; +import { AnyBoardDo, EntityId, SubmissionContainerElement, SubmissionItem, UserRoleEnum } from '@shared/domain'; import { Logger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; import { Action } from '@src/modules/authorization/types/action.enum'; @@ -32,7 +32,11 @@ export class ElementUc { await this.elementService.update(element, content); } - async createSubmissionItem(userId: EntityId, contentElementId: EntityId): Promise { + async createSubmissionItem( + userId: EntityId, + contentElementId: EntityId, + completed: boolean + ): Promise { const submissionContainer = (await this.elementService.findById(contentElementId)) as SubmissionContainerElement; if (!(submissionContainer instanceof SubmissionContainerElement)) @@ -47,24 +51,32 @@ export class ElementUc { HttpStatus.UNPROCESSABLE_ENTITY ); - const userExists = submissionContainer.children.find((item) => (item as SubmissionItem).userId === userId); - if (userExists) { + const userSubmissionExists = submissionContainer.children.find( + (item) => (item as SubmissionItem).userId === userId + ); + if (userSubmissionExists) { throw new HttpException( 'User is not allowed to have multiple submission-items per submission-container-element', HttpStatus.NOT_ACCEPTABLE ); } - await this.checkPermission(userId, submissionContainer, Action.write); + await this.checkPermission(userId, submissionContainer, Action.read, UserRoleEnum.STUDENT); - const subElement = await this.submissionItemService.create(userId, submissionContainer, { completed: false }); + const submissionItem = await this.submissionItemService.create(userId, submissionContainer, { completed }); - return subElement; + return submissionItem; } - private async checkPermission(userId: EntityId, boardDo: AnyBoardDo, action: Action): Promise { + private async checkPermission( + userId: EntityId, + boardDo: AnyBoardDo, + action: Action, + requiredUserRole?: UserRoleEnum + ): Promise { const user = await this.authorizationService.getUserWithPermissions(userId); const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(boardDo); + if (requiredUserRole) boardDoAuthorizable.requiredUserRole = requiredUserRole; const context = { action, requiredPermissions: [] }; return this.authorizationService.checkPermission(user, boardDoAuthorizable, context); diff --git a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts new file mode 100644 index 00000000000..8e47bd23013 --- /dev/null +++ b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts @@ -0,0 +1,264 @@ +import { ObjectId } from 'bson'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain'; +import { + fileElementFactory, + setupEntities, + submissionContainerElementFactory, + submissionItemFactory, + userFactory, +} from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { AuthorizationService } from '@src/modules/authorization'; +import { Action } from '@src/modules/authorization/types/action.enum'; +import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; +import { SubmissionItemUc } from './submission-item.uc'; + +describe(SubmissionItemUc.name, () => { + let module: TestingModule; + let uc: SubmissionItemUc; + let authorizationService: DeepMocked; + let boardDoAuthorizableService: DeepMocked; + let elementService: DeepMocked; + let submissionItemService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SubmissionItemUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: BoardDoAuthorizableService, + useValue: createMock(), + }, + { + provide: ContentElementService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: SubmissionItemService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(SubmissionItemUc); + authorizationService = module.get(AuthorizationService); + authorizationService.checkPermission.mockImplementation(() => {}); + boardDoAuthorizableService = module.get(BoardDoAuthorizableService); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( + new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) + ); + elementService = module.get(ContentElementService); + submissionItemService = module.get(SubmissionItemService); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findSubmissionItems', () => { + describe('user is student', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const submissionItem1 = submissionItemFactory.build({ + userId: user1.id, + }); + const submissionItem2 = submissionItemFactory.build({ + userId: user2.id, + }); + const submissionContainerEl = submissionContainerElementFactory.build({ + children: [submissionItem1, submissionItem2], + }); + + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( + new BoardDoAuthorizable({ + users: [ + { userId: user1.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + { userId: user2.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + ], + id: submissionContainerEl.id, + }) + ); + + const elementSpy = elementService.findById.mockResolvedValueOnce(submissionContainerEl); + + return { submissionContainerEl, submissionItem1, user1, elementSpy }; + }; + + it('student1 should only get their own submission item', async () => { + const { user1, submissionContainerEl, submissionItem1 } = setup(); + const items = await uc.findSubmissionItems(user1.id, submissionContainerEl.id); + expect(items.length).toBe(1); + expect(items[0]).toStrictEqual(submissionItem1); + }); + }); + describe('when user is a teacher', () => { + const setup = () => { + const teacher = userFactory.buildWithId(); + const student1 = userFactory.buildWithId(); + const student2 = userFactory.buildWithId(); + const submissionItem1 = submissionItemFactory.build({ + userId: student1.id, + }); + const submissionItem2 = submissionItemFactory.build({ + userId: student2.id, + }); + const submissionContainerEl = submissionContainerElementFactory.build({ + children: [submissionItem1, submissionItem2], + }); + + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( + new BoardDoAuthorizable({ + users: [ + { userId: teacher.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }, + { userId: student1.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + { userId: student2.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + ], + id: submissionContainerEl.id, + }) + ); + + const elementSpy = elementService.findById.mockResolvedValue(submissionContainerEl); + + return { submissionContainerEl, submissionItem1, submissionItem2, teacher, elementSpy }; + }; + + it('teacher should get all submission items', async () => { + const { teacher, submissionContainerEl, submissionItem1, submissionItem2 } = setup(); + const items = await uc.findSubmissionItems(teacher.id, submissionContainerEl.id); + expect(items.length).toBe(2); + expect(items.map((item) => item.id)).toContain(submissionItem1.id); + expect(items.map((item) => item.id)).toContain(submissionItem2.id); + }); + }); + describe('when user has not an authorized role', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build({ + userId: user.id, + }); + const submissionContainerEl = submissionContainerElementFactory.build({ + children: [submissionItem], + }); + + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( + new BoardDoAuthorizable({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: submissionContainerEl.id, + }) + ); + elementService.findById.mockResolvedValueOnce(submissionContainerEl); + + return { user, submissionContainerElement: submissionContainerEl }; + }; + it('should throw forbidden exception', async () => { + const { user, submissionContainerElement } = setup(); + + await expect(uc.findSubmissionItems(user.id, submissionContainerElement.id)).rejects.toThrow( + 'User not part of this board' + ); + }); + }); + describe('when called with wrong board node', () => { + const setup = () => { + const teacher = userFactory.buildWithId(); + const fileEl = fileElementFactory.build(); + elementService.findById.mockResolvedValue(fileEl); + + return { teacher, fileEl }; + }; + + it('should throw HttpException', async () => { + const { teacher, fileEl } = setup(); + + await expect(uc.findSubmissionItems(teacher.id, fileEl.id)).rejects.toThrow( + 'Id does not belong to a submission container' + ); + }); + }); + describe('when called with invalid submission container children', () => { + const setup = () => { + const teacher = userFactory.buildWithId(); + const fileEl = fileElementFactory.build(); + const submissionContainer = submissionContainerElementFactory.build({ + children: [fileEl], + }); + elementService.findById.mockResolvedValue(submissionContainer); + + return { teacher, submissionContainer }; + }; + + it('should throw HttpException', async () => { + const { teacher, submissionContainer } = setup(); + + await expect(uc.findSubmissionItems(teacher.id, submissionContainer.id)).rejects.toThrow( + 'Children of submission-container-element must be of type submission-item' + ); + }); + }); + }); + + describe('updateSubmissionItem', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + const submissionItem = submissionItemFactory.build({ + userId: user.id, + }); + + submissionItemService.findById.mockResolvedValueOnce(submissionItem); + + return { submissionItem, user, boardDoAuthorizableService }; + }; + + it('should call service to find the submission item ', async () => { + const { submissionItem, user } = setup(); + await uc.updateSubmissionItem(user.id, submissionItem.id, false); + expect(submissionItemService.findById).toHaveBeenCalledWith(submissionItem.id); + }); + + it('should authorize', async () => { + const { submissionItem, user } = setup(); + + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( + new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }], + id: submissionItem.id, + }) + ); + const boardDoAuthorizable = await boardDoAuthorizableService.getBoardAuthorizable(submissionItem); + + await uc.updateSubmissionItem(user.id, submissionItem.id, false); + const context = { action: Action.read, requiredPermissions: [] }; + expect(authorizationService.checkPermission).toBeCalledWith(user, boardDoAuthorizable, context); + }); + it('should throw if user is not creator of submission', async () => { + const user2 = userFactory.buildWithId(); + const { submissionItem } = setup(); + + await expect(uc.updateSubmissionItem(user2.id, submissionItem.id, false)).rejects.toThrow(); + }); + it('should call service to update element', async () => { + const { submissionItem, user } = setup(); + await uc.updateSubmissionItem(user.id, submissionItem.id, false); + expect(submissionItemService.update).toHaveBeenCalledWith(submissionItem, false); + }); + }); +}); diff --git a/apps/server/src/modules/board/uc/submission-item.uc.ts b/apps/server/src/modules/board/uc/submission-item.uc.ts new file mode 100644 index 00000000000..ec15b9322e3 --- /dev/null +++ b/apps/server/src/modules/board/uc/submission-item.uc.ts @@ -0,0 +1,102 @@ +import { ForbiddenException, forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { AnyBoardDo, EntityId, SubmissionContainerElement, SubmissionItem, UserRoleEnum } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { AuthorizationService } from '@src/modules/authorization'; +import { Action } from '@src/modules/authorization/types/action.enum'; +import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; + +@Injectable() +export class SubmissionItemUc { + constructor( + @Inject(forwardRef(() => AuthorizationService)) + private readonly authorizationService: AuthorizationService, + private readonly boardDoAuthorizableService: BoardDoAuthorizableService, + private readonly elementService: ContentElementService, + private readonly submissionItemService: SubmissionItemService, + private readonly logger: Logger + ) { + this.logger.setContext(SubmissionItemUc.name); + } + + async findSubmissionItems(userId: EntityId, submissionContainerId: EntityId): Promise { + const submissionContainer = await this.getSubmissionContainer(submissionContainerId); + await this.checkPermission(userId, submissionContainer, Action.read); + + let submissionItems = submissionContainer.children as SubmissionItem[]; + + if (!submissionItems.every((child) => child instanceof SubmissionItem)) { + throw new HttpException( + 'Children of submission-container-element must be of type submission-item', + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + const isAuthorizedStudent = await this.isAuthorizedStudent(userId, submissionContainer); + if (isAuthorizedStudent) { + submissionItems = submissionItems.filter((item) => item.userId === userId); + } + + return submissionItems; + } + + async updateSubmissionItem( + userId: EntityId, + submissionItemId: EntityId, + completed: boolean + ): Promise { + const submissionItem = await this.submissionItemService.findById(submissionItemId); + + await this.checkPermission(userId, submissionItem, Action.read, UserRoleEnum.STUDENT); + if (submissionItem.userId !== userId) { + throw new ForbiddenException(); + } + + await this.submissionItemService.update(submissionItem, completed); + + return submissionItem; + } + + private async isAuthorizedStudent(userId: EntityId, boardDo: AnyBoardDo): Promise { + const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(boardDo); + const userRoleEnum = boardDoAuthorizable.users.find((u) => u.userId === userId)?.userRoleEnum; + + if (!userRoleEnum) { + throw new ForbiddenException('User not part of this board'); + } + + // TODO do this with permission instead of role and using authorizable rules + if (userRoleEnum === UserRoleEnum.STUDENT) { + return true; + } + + return false; + } + + private async getSubmissionContainer(submissionContainerId: EntityId): Promise { + const submissionContainer = (await this.elementService.findById( + submissionContainerId + )) as SubmissionContainerElement; + + if (!(submissionContainer instanceof SubmissionContainerElement)) { + throw new HttpException('Id does not belong to a submission container', HttpStatus.UNPROCESSABLE_ENTITY); + } + + return submissionContainer; + } + + private async checkPermission( + userId: EntityId, + boardDo: AnyBoardDo, + action: Action, + requiredUserRole?: UserRoleEnum + ): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(boardDo); + if (requiredUserRole) { + boardDoAuthorizable.requiredUserRole = requiredUserRole; + } + const context = { action, requiredPermissions: [] }; + + return this.authorizationService.checkPermission(user, boardDoAuthorizable, context); + } +} diff --git a/apps/server/src/modules/class/class.module.ts b/apps/server/src/modules/class/class.module.ts new file mode 100644 index 00000000000..550b50bd454 --- /dev/null +++ b/apps/server/src/modules/class/class.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ClassService } from './service'; +import { ClassesRepo } from './repo'; + +@Module({ + providers: [ClassService, ClassesRepo], + exports: [ClassService], +}) +export class ClassModule {} diff --git a/apps/server/src/modules/class/domain/class-source-options.do.ts b/apps/server/src/modules/class/domain/class-source-options.do.ts new file mode 100644 index 00000000000..8340c92b338 --- /dev/null +++ b/apps/server/src/modules/class/domain/class-source-options.do.ts @@ -0,0 +1,15 @@ +export interface ClassSourceOptionsProps { + tspUid?: string; +} + +export class ClassSourceOptions { + protected props: ClassSourceOptionsProps; + + constructor(props: ClassSourceOptionsProps) { + this.props = props; + } + + get tspUid(): string | undefined { + return this.props.tspUid; + } +} diff --git a/apps/server/src/modules/class/domain/class.do.ts b/apps/server/src/modules/class/domain/class.do.ts new file mode 100644 index 00000000000..fd6449a9d46 --- /dev/null +++ b/apps/server/src/modules/class/domain/class.do.ts @@ -0,0 +1,77 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '../../../shared/domain/domain-object'; +import { ClassSourceOptions } from './class-source-options.do'; + +export interface ClassProps extends AuthorizableObject { + name: string; + schoolId: EntityId; + userIds?: EntityId[]; + teacherIds: EntityId[]; + invitationLink?: string; + year?: EntityId; + gradeLevel?: number; + ldapDN?: string; + successor?: EntityId; + source?: string; + sourceOptions?: ClassSourceOptions; + createdAt: Date; + updatedAt: Date; +} + +export class Class extends DomainObject { + get name(): string { + return this.props.name; + } + + get schoolId(): EntityId { + return this.props.schoolId; + } + + get userIds(): EntityId[] | undefined { + return this.props.userIds; + } + + get teacherIds(): EntityId[] { + return this.props.teacherIds; + } + + get invitationLink(): string | undefined { + return this.props.invitationLink; + } + + get year(): EntityId | undefined { + return this.props.year; + } + + get gradeLevel(): number | undefined { + return this.props.gradeLevel; + } + + get ldapDN(): string | undefined { + return this.props.ldapDN; + } + + get successor(): EntityId | undefined { + return this.props.successor; + } + + get source(): string | undefined { + return this.props.source; + } + + get sourceOptions(): ClassSourceOptions | undefined { + return this.props.sourceOptions; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + public removeUser(userId: string) { + this.props.userIds = this.props.userIds?.filter((userId1) => userId1 !== userId); + } +} diff --git a/apps/server/src/modules/class/domain/index.ts b/apps/server/src/modules/class/domain/index.ts new file mode 100644 index 00000000000..06c22071d2c --- /dev/null +++ b/apps/server/src/modules/class/domain/index.ts @@ -0,0 +1 @@ +export * from './class.do'; diff --git a/apps/server/src/modules/class/domain/testing/class-source-options.do.spec.ts b/apps/server/src/modules/class/domain/testing/class-source-options.do.spec.ts new file mode 100644 index 00000000000..6ca0b4a11aa --- /dev/null +++ b/apps/server/src/modules/class/domain/testing/class-source-options.do.spec.ts @@ -0,0 +1,43 @@ +import { ClassSourceOptions } from '../class-source-options.do'; + +describe(ClassSourceOptions.name, () => { + describe('constructor', () => { + describe('When a contructor is called', () => { + const setup = () => { + const domainObject = new ClassSourceOptions({ tspUid: '12345' }); + + return { domainObject }; + }; + + it('should create empty object', () => { + const domainObject = new ClassSourceOptions({}); + + expect(domainObject).toEqual(expect.objectContaining({})); + }); + + it('should contain valid tspUid ', () => { + const { domainObject } = setup(); + + const classSourceOptionsDo: ClassSourceOptions = new ClassSourceOptions(domainObject); + + expect(classSourceOptionsDo.tspUid).toEqual(domainObject.tspUid); + }); + }); + }); + describe('getters', () => { + describe('When getters are used', () => { + it('getters should return proper value', () => { + const props = { + tspUid: '12345', + }; + + const classSourceOptionsDo = new ClassSourceOptions(props); + const gettersValues = { + tspUid: classSourceOptionsDo.tspUid, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/domain/testing/class.do.spec.ts b/apps/server/src/modules/class/domain/testing/class.do.spec.ts new file mode 100644 index 00000000000..510af786665 --- /dev/null +++ b/apps/server/src/modules/class/domain/testing/class.do.spec.ts @@ -0,0 +1,89 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Class } from '../class.do'; +import { classFactory } from './factory/class.factory'; +import { ClassSourceOptions } from '../class-source-options.do'; + +describe(Class.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a class by passing required properties', () => { + const domainObject: Class = classFactory.build(); + + expect(domainObject instanceof Class).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: Class = classFactory.build({ id: new ObjectId().toHexString() }); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const classDomainObject: Class = new Class(domainObject); + + expect(classDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + describe('getters', () => { + describe('When getters are used', () => { + it('getters should return proper values', () => { + const props = { + id: new ObjectId().toHexString(), + name: `name1`, + schoolId: new ObjectId().toHexString(), + userIds: [new ObjectId().toHexString(), new ObjectId().toHexString()], + teacherIds: [new ObjectId().toHexString(), new ObjectId().toHexString()], + invitationLink: `link-1`, + year: new ObjectId().toHexString(), + gradeLevel: 1, + ldapDN: `dn-$1`, + successor: new ObjectId().toHexString(), + source: `source-1`, + sourceOptions: new ClassSourceOptions({ tspUid: `id-1` }), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const classDo = new Class(props); + const gettersValues = { + id: classDo.id, + name: classDo.name, + schoolId: classDo.schoolId, + userIds: classDo.userIds, + teacherIds: classDo.teacherIds, + invitationLink: classDo.invitationLink, + year: classDo.year, + gradeLevel: classDo.gradeLevel, + ldapDN: classDo.ldapDN, + successor: classDo.successor, + source: classDo.source, + sourceOptions: classDo.sourceOptions, + createdAt: classDo.createdAt, + updatedAt: classDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); + describe('removeUsers', () => { + describe('When function is called', () => { + it('domainObject userIds table should be updated and userId should be removed', () => { + const userToDeleteId = new ObjectId().toHexString(); + const user2 = new ObjectId().toHexString(); + const user3 = new ObjectId().toHexString(); + + const domainObject = classFactory.withUserIds([userToDeleteId, user2, user3]).build(); + + domainObject.removeUser(userToDeleteId); + + expect(domainObject.userIds).toEqual([user2, user3]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/domain/testing/factory/class.factory.ts b/apps/server/src/modules/class/domain/testing/factory/class.factory.ts new file mode 100644 index 00000000000..63ae07d6809 --- /dev/null +++ b/apps/server/src/modules/class/domain/testing/factory/class.factory.ts @@ -0,0 +1,34 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeepPartial } from 'fishery'; +import { Class, ClassProps } from '../../class.do'; +import { ClassSourceOptions } from '../../class-source-options.do'; + +class ClassFactory extends DoBaseFactory { + withUserIds(userIds: string[]): this { + const params: DeepPartial = { + userIds, + }; + + return this.params(params); + } +} + +export const classFactory = ClassFactory.define(Class, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + name: `name-${sequence}`, + schoolId: new ObjectId().toHexString(), + userIds: [new ObjectId().toHexString(), new ObjectId().toHexString()], + teacherIds: [new ObjectId().toHexString(), new ObjectId().toHexString()], + invitationLink: `link-${sequence}`, + year: new ObjectId().toHexString(), + gradeLevel: sequence, + ldapDN: `dn-${sequence}`, + successor: new ObjectId().toHexString(), + source: `source-${sequence}`, + sourceOptions: new ClassSourceOptions({ tspUid: `id-${sequence}` }), + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/class/entity/class-source-options.entity.ts b/apps/server/src/modules/class/entity/class-source-options.entity.ts new file mode 100644 index 00000000000..4d85ade8b7b --- /dev/null +++ b/apps/server/src/modules/class/entity/class-source-options.entity.ts @@ -0,0 +1,17 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +export interface ClassSourceOptionsEntityProps { + tspUid?: string; +} + +@Embeddable() +export class ClassSourceOptionsEntity { + @Property({ nullable: true }) + tspUid?: string; + + constructor(props: ClassSourceOptionsEntityProps) { + if (props.tspUid !== undefined) { + this.tspUid = props.tspUid; + } + } +} diff --git a/apps/server/src/modules/class/entity/class.entity.ts b/apps/server/src/modules/class/entity/class.entity.ts new file mode 100644 index 00000000000..3f08ba49582 --- /dev/null +++ b/apps/server/src/modules/class/entity/class.entity.ts @@ -0,0 +1,111 @@ +import { Embedded, Entity, Index, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; +import { ClassSourceOptionsEntity } from './class-source-options.entity'; + +export interface IClassEntityProps { + id?: EntityId; + name: string; + schoolId: ObjectId; + userIds?: ObjectId[]; + teacherIds: ObjectId[]; + invitationLink?: string; + year?: ObjectId; + gradeLevel?: number; + ldapDN?: string; + successor?: ObjectId; + source?: string; + sourceOptions?: ClassSourceOptionsEntity; +} + +@Entity({ tableName: 'classes' }) +@Index({ properties: ['year', 'ldapDN'] }) +export class ClassEntity extends BaseEntityWithTimestamps { + @Property() + name: string; + + @Property() + @Index() + schoolId: ObjectId; + + @Property({ nullable: true }) + @Index() + userIds?: ObjectId[]; + + @Property() + @Index() + teacherIds: ObjectId[]; + + @Property({ nullable: true }) + invitationLink?: string; + + @Property({ nullable: true }) + year?: ObjectId; + + @Property({ nullable: true }) + gradeLevel?: number; + + @Property({ nullable: true }) + ldapDN?: string; + + @Property({ nullable: true }) + successor?: ObjectId; + + @Property({ nullable: true }) + @Index() + source?: string; + + @Embedded(() => ClassSourceOptionsEntity, { object: true, nullable: true }) + sourceOptions?: ClassSourceOptionsEntity; + + private validate(props: IClassEntityProps) { + if (props.gradeLevel !== undefined && (props.gradeLevel < 1 || props.gradeLevel > 13)) { + throw new Error('gradeLevel must be value beetween 1 and 13'); + } + } + + constructor(props: IClassEntityProps) { + super(); + this.validate(props); + + if (props.id !== undefined) { + this.id = props.id; + } + + this.name = props.name; + this.schoolId = props.schoolId; + + if (props.userIds !== undefined) { + this.userIds = props.userIds; + } + + this.teacherIds = props.teacherIds; + + if (props.invitationLink !== undefined) { + this.invitationLink = props.invitationLink; + } + + if (props.year !== undefined) { + this.year = props.year; + } + if (props.gradeLevel !== undefined) { + this.gradeLevel = props.gradeLevel; + } + if (props.ldapDN !== undefined) { + this.ldapDN = props.ldapDN; + } + + if (props.successor !== undefined) { + this.successor = props.successor; + } + + if (props.source !== undefined) { + this.source = props.source; + } + + if (props.sourceOptions !== undefined) { + this.sourceOptions = props.sourceOptions; + } + } +} diff --git a/apps/server/src/modules/class/entity/index.ts b/apps/server/src/modules/class/entity/index.ts new file mode 100644 index 00000000000..faf5ab7557b --- /dev/null +++ b/apps/server/src/modules/class/entity/index.ts @@ -0,0 +1,2 @@ +export * from './class.entity'; +export * from './class-source-options.entity'; diff --git a/apps/server/src/modules/class/entity/testing/class-source-options.entity.spec.ts b/apps/server/src/modules/class/entity/testing/class-source-options.entity.spec.ts new file mode 100644 index 00000000000..13cc083b162 --- /dev/null +++ b/apps/server/src/modules/class/entity/testing/class-source-options.entity.spec.ts @@ -0,0 +1,27 @@ +import { ClassSourceOptionsEntity } from '../class-source-options.entity'; + +describe(ClassSourceOptionsEntity.name, () => { + describe('constructor', () => { + describe('When a contructor is called', () => { + const setup = () => { + const entity = new ClassSourceOptionsEntity({ tspUid: '12345' }); + + return { entity }; + }; + + it('should create empty object', () => { + const entity = new ClassSourceOptionsEntity({}); + + expect(entity).toEqual(expect.objectContaining({})); + }); + + it('should contain valid tspUid ', () => { + const { entity } = setup(); + + const classSourceOptionsEntity: ClassSourceOptionsEntity = new ClassSourceOptionsEntity(entity); + + expect(classSourceOptionsEntity.tspUid).toEqual(entity.tspUid); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/entity/testing/class.entity.spec.ts b/apps/server/src/modules/class/entity/testing/class.entity.spec.ts new file mode 100644 index 00000000000..eb16f608238 --- /dev/null +++ b/apps/server/src/modules/class/entity/testing/class.entity.spec.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-new */ +import { setupEntities } from '@shared/testing'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ClassEntity } from '../class.entity'; + +describe(ClassEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('When wrong gradeLevel value is passed', () => { + it('should throw an error by wrong gradeLevel value', () => { + expect(() => { + new ClassEntity({ + name: 'classTest', + schoolId: new ObjectId(), + teacherIds: [new ObjectId()], + gradeLevel: 0, + }); + }).toThrow(); + + expect(() => { + new ClassEntity({ + name: 'classTest', + schoolId: new ObjectId(), + teacherIds: [new ObjectId()], + gradeLevel: 14, + }); + }).toThrow(); + }); + }); + + describe('When constructor is called', () => { + it('should create a class by passing required properties', () => { + const entity: ClassEntity = classEntityFactory.build(); + + expect(entity instanceof ClassEntity).toEqual(true); + }); + }); + + describe('when passed undefined id', () => { + const setup = () => { + const entity: ClassEntity = classEntityFactory.build(); + + return { entity }; + }; + + it('should not set the id', () => { + const { entity } = setup(); + + const classEntity = new ClassEntity(entity); + + expect(classEntity.id).toBeNull(); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const entity: ClassEntity = classEntityFactory.build({ id: new ObjectId().toHexString() }); + + return { entity }; + }; + + it('should set the id', () => { + const { entity } = setup(); + + const classEntity: ClassEntity = new ClassEntity(entity); + + expect(classEntity.id).toEqual(entity.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts b/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts new file mode 100644 index 00000000000..68e7514d3bc --- /dev/null +++ b/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts @@ -0,0 +1,30 @@ +import { DeepPartial } from 'fishery'; +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { ClassEntity, ClassSourceOptionsEntity, IClassEntityProps } from '@src/modules/class/entity'; +import { ObjectId } from 'bson'; + +class ClassEntityFactory extends BaseFactory { + withUserIds(userIds: ObjectId[]): this { + const params: DeepPartial = { + userIds, + }; + + return this.params(params); + } +} + +export const classEntityFactory = ClassEntityFactory.define(ClassEntity, ({ sequence }) => { + return { + name: `name-${sequence}`, + schoolId: new ObjectId(), + userIds: new Array(), + teacherIds: [new ObjectId(), new ObjectId()], + invitationLink: `link-${sequence}`, + year: new ObjectId(), + gradeLevel: sequence, + ldapDN: `dn-${sequence}`, + successor: new ObjectId(), + source: `source-${sequence}`, + sourceOptions: new ClassSourceOptionsEntity({ tspUid: `id-${sequence}` }), + }; +}); diff --git a/apps/server/src/modules/class/index.ts b/apps/server/src/modules/class/index.ts new file mode 100644 index 00000000000..cf226f1b02e --- /dev/null +++ b/apps/server/src/modules/class/index.ts @@ -0,0 +1,2 @@ +export * from './class.module'; +export * from './service'; diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts new file mode 100644 index 00000000000..c7c519c9435 --- /dev/null +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -0,0 +1,114 @@ +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { Test } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { ClassesRepo } from './classes.repo'; +import { ClassEntity } from '../entity'; +import { ClassMapper } from './mapper'; +import { Class } from '../domain'; + +describe(ClassesRepo.name, () => { + let module: TestingModule; + let repo: ClassesRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [ClassesRepo, ClassMapper], + }).compile(); + + repo = module.get(ClassesRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findAllByUserId', () => { + describe('when user is not found in classes', () => { + it('should return empty array', async () => { + const result = await repo.findAllByUserId(new ObjectId().toHexString()); + + expect(result).toEqual([]); + }); + }); + describe('when user is in classes', () => { + const setup = async () => { + const testUser = new ObjectId(); + const class1: ClassEntity = classEntityFactory.withUserIds([testUser, new ObjectId()]).buildWithId(); + const class2: ClassEntity = classEntityFactory.withUserIds([testUser, new ObjectId()]).buildWithId(); + const class3: ClassEntity = classEntityFactory.withUserIds([new ObjectId(), new ObjectId()]).buildWithId(); + await em.persistAndFlush([class1, class2, class3]); + + return { + class1, + class2, + class3, + }; + }; + + it('should find classes with particular userId', async () => { + const { class1, class2 } = await setup(); + + const a = class1.userIds?.at(0) as ObjectId; + const result = await repo.findAllByUserId(a.toHexString()); + + expect(result.length).toEqual(2); + + expect(result[0].id).toEqual(class1.id); + expect(result[1].id).toEqual(class2.id); + }); + }); + }); + + describe('updateMany', () => { + describe('When deleting user data from classes', () => { + const setup = async () => { + const testUser1 = new ObjectId(); + const testUser2 = new ObjectId(); + const testUser3 = new ObjectId(); + const class1: ClassEntity = classEntityFactory.withUserIds([testUser1, testUser2]).buildWithId(); + const class2: ClassEntity = classEntityFactory.withUserIds([testUser1, testUser3]).buildWithId(); + const class3: ClassEntity = classEntityFactory.withUserIds([testUser2, testUser3]).buildWithId(); + await em.persistAndFlush([class1, class2, class3]); + + return { + class1, + class2, + testUser1, + testUser2, + testUser3, + }; + }; + + it('should update classes without deleted user', async () => { + const { class1, class2, testUser1, testUser2, testUser3 } = await setup(); + + class1.userIds = [testUser2]; + class2.userIds = [testUser3]; + + const updatedArray: ClassEntity[] = [class1, class2]; + const domainObjectsArray: Class[] = ClassMapper.mapToDOs(updatedArray); + + await repo.updateMany(domainObjectsArray); + + const result1 = await repo.findAllByUserId(testUser1.toHexString()); + expect(result1).toHaveLength(0); + + const result2 = await repo.findAllByUserId(testUser2.toHexString()); + expect(result2).toHaveLength(2); + + const result3 = await repo.findAllByUserId(testUser3.toHexString()); + expect(result3).toHaveLength(2); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts new file mode 100644 index 00000000000..1e4b3ba750e --- /dev/null +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain'; +import { ClassEntity } from '../entity'; +import { Class } from '../domain'; +import { ClassMapper } from './mapper'; + +@Injectable() +export class ClassesRepo { + constructor(private readonly em: EntityManager, private readonly mapper: ClassMapper) {} + + async findAllByUserId(userId: EntityId): Promise { + const classes: ClassEntity[] = await this.em.find(ClassEntity, { userIds: new ObjectId(userId) }); + return ClassMapper.mapToDOs(classes); + } + + async updateMany(classes: Class[]): Promise { + const classesEntities = ClassMapper.mapToEntities(classes); + const referencedEntities = classesEntities.map((classEntity) => this.em.getReference(ClassEntity, classEntity.id)); + + await this.em.persistAndFlush(referencedEntities); + } +} diff --git a/apps/server/src/modules/class/repo/index.ts b/apps/server/src/modules/class/repo/index.ts new file mode 100644 index 00000000000..d93472feca7 --- /dev/null +++ b/apps/server/src/modules/class/repo/index.ts @@ -0,0 +1 @@ +export * from './classes.repo'; diff --git a/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts b/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts new file mode 100644 index 00000000000..ce7a357e38e --- /dev/null +++ b/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts @@ -0,0 +1,87 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { ClassEntity } from '../../entity'; +import { ClassMapper } from './class.mapper'; +import { Class } from '../../domain'; +import { ClassSourceOptions } from '../../domain/class-source-options.do'; +import { classFactory } from '../../domain/testing/factory/class.factory'; +import { classEntityFactory } from '../../entity/testing/factory/class.entity.factory'; + +describe(ClassMapper.name, () => { + 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 = ClassMapper.mapToDOs([]); + + expect(domainObjects).toEqual([]); + }); + }); + + describe('When entities array is mapped for domainObjects array', () => { + it('should properly map the entities to the domain objects', () => { + const entities = [classEntityFactory.build()]; + + const domainObjects = ClassMapper.mapToDOs(entities); + + const expectedDomainObjects = entities.map( + (entity) => + new Class({ + id: entity.id, + name: entity.name, + schoolId: entity.schoolId.toHexString(), + invitationLink: entity.invitationLink, + ldapDN: entity.ldapDN, + source: entity.source, + sourceOptions: new ClassSourceOptions({ + tspUid: entity.sourceOptions?.tspUid, + }), + userIds: entity.userIds?.map((userId) => userId.toHexString()), + successor: entity.successor?.toHexString(), + teacherIds: entity.teacherIds.map((teacherId) => teacherId.toHexString()), + gradeLevel: entity.gradeLevel, + year: entity.year?.toHexString(), + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }) + ); + + expect(domainObjects).toEqual(expectedDomainObjects); + }); + }); + }); + + 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 = ClassMapper.mapToEntities([]); + + expect(entities).toEqual([]); + }); + }); + describe('When domainObjects array is mapped for entities array', () => { + it('should properly map the domainObjects to the entities', () => { + const domainObjects = [classFactory.build()]; + + const entities = ClassMapper.mapToEntities(domainObjects); + + const expectedEntities = domainObjects.map( + (domainObject) => + new ClassEntity({ + id: domainObject.id, + name: domainObject.name, + schoolId: new ObjectId(domainObject.schoolId), + teacherIds: domainObject.teacherIds.map((teacherId) => new ObjectId(teacherId)), + invitationLink: domainObject.invitationLink, + ldapDN: domainObject.ldapDN, + source: domainObject.source, + gradeLevel: domainObject.gradeLevel, + sourceOptions: domainObject.sourceOptions, + successor: new ObjectId(domainObject.successor), + userIds: domainObject.userIds?.map((userId) => new ObjectId(userId)), + year: new ObjectId(domainObject.year), + }) + ); + expect(entities).toEqual(expectedEntities); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/repo/mapper/class.mapper.ts b/apps/server/src/modules/class/repo/mapper/class.mapper.ts new file mode 100644 index 00000000000..6340ffce7b0 --- /dev/null +++ b/apps/server/src/modules/class/repo/mapper/class.mapper.ts @@ -0,0 +1,50 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Class } from '../../domain'; +import { ClassSourceOptions } from '../../domain/class-source-options.do'; +import { ClassEntity } from '../../entity'; + +export class ClassMapper { + private static mapToDO(entity: ClassEntity): Class { + return new Class({ + id: entity.id, + name: entity.name, + schoolId: entity.schoolId.toHexString(), + userIds: entity.userIds?.map((userId) => userId.toHexString()), + teacherIds: entity.teacherIds.map((teacherId) => teacherId.toHexString()), + invitationLink: entity.invitationLink, + year: entity.year?.toHexString(), + gradeLevel: entity.gradeLevel, + ldapDN: entity.ldapDN, + successor: entity.successor?.toHexString(), + source: entity.source, + sourceOptions: new ClassSourceOptions({ tspUid: entity.sourceOptions?.tspUid }), + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + } + + private static mapToEntity(domainObject: Class): ClassEntity { + return new ClassEntity({ + id: domainObject.id, + name: domainObject.name, + schoolId: new ObjectId(domainObject.schoolId), + teacherIds: domainObject.teacherIds.map((teacherId) => new ObjectId(teacherId)), + userIds: domainObject.userIds?.map((userId) => new ObjectId(userId)), + invitationLink: domainObject.invitationLink, + year: domainObject.year !== undefined ? new ObjectId(domainObject.year) : undefined, + gradeLevel: domainObject.gradeLevel, + ldapDN: domainObject.ldapDN, + successor: domainObject.successor !== undefined ? new ObjectId(domainObject.successor) : undefined, + source: domainObject.source, + sourceOptions: domainObject.sourceOptions, + }); + } + + static mapToDOs(entities: ClassEntity[]): Class[] { + return entities.map((entity) => this.mapToDO(entity)); + } + + static mapToEntities(domainObjects: Class[]): ClassEntity[] { + return domainObjects.map((domainObject) => this.mapToEntity(domainObject)); + } +} diff --git a/apps/server/src/modules/class/repo/mapper/index.ts b/apps/server/src/modules/class/repo/mapper/index.ts new file mode 100644 index 00000000000..5b0298fae64 --- /dev/null +++ b/apps/server/src/modules/class/repo/mapper/index.ts @@ -0,0 +1 @@ +export * from './class.mapper'; diff --git a/apps/server/src/modules/class/service/class.service.spec.ts b/apps/server/src/modules/class/service/class.service.spec.ts new file mode 100644 index 00000000000..c605f4de7cd --- /dev/null +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { EntityId } from '@shared/domain'; +import { InternalServerErrorException } from '@nestjs/common'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { setupEntities } from '@shared/testing'; +import { ClassService } from './class.service'; +import { ClassesRepo } from '../repo'; +import { ClassMapper } from '../repo/mapper'; + +describe(ClassService.name, () => { + let module: TestingModule; + let service: ClassService; + let classesRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ClassService, + { + provide: ClassesRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ClassService); + classesRepo = module.get(ClassesRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteUserDataFromClasses', () => { + describe('when user is missing', () => { + const setup = () => { + const userId = undefined as unknown as EntityId; + + return { + userId, + }; + }; + + it('should throw and error', async () => { + const { userId } = setup(); + + await expect(service.deleteUserDataFromClasses(userId)).rejects.toThrowError(InternalServerErrorException); + }); + }); + + describe('when deleting by userId', () => { + const setup = () => { + const userId1 = new ObjectId(); + const userId2 = new ObjectId(); + const userId3 = new ObjectId(); + const class1 = classEntityFactory.withUserIds([userId1, userId2]).build(); + const class2 = classEntityFactory.withUserIds([userId1, userId3]).build(); + classEntityFactory.withUserIds([userId2, userId3]).build(); + + const mappedClasses = ClassMapper.mapToDOs([class1, class2]); + + classesRepo.findAllByUserId.mockResolvedValue(mappedClasses); + + return { + userId1, + }; + }; + + it('should call classesRepo.findAllByUserId', async () => { + const { userId1 } = setup(); + await service.deleteUserDataFromClasses(userId1.toHexString()); + + expect(classesRepo.findAllByUserId).toBeCalledWith(userId1.toHexString()); + }); + + it('should update classes without updated user', async () => { + const { userId1 } = setup(); + + const result = await service.deleteUserDataFromClasses(userId1.toHexString()); + + expect(result).toEqual(2); + }); + }); + }); +}); diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts new file mode 100644 index 00000000000..30ca6602d8f --- /dev/null +++ b/apps/server/src/modules/class/service/class.service.ts @@ -0,0 +1,28 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ClassesRepo } from '../repo'; +import { Class } from '../domain'; + +@Injectable() +export class ClassService { + constructor(private readonly classesRepo: ClassesRepo) {} + + public async deleteUserDataFromClasses(userId: EntityId): Promise { + if (!userId) { + throw new InternalServerErrorException('User id is missing'); + } + + const domainObjects = await this.classesRepo.findAllByUserId(userId); + + const updatedClasses: Class[] = domainObjects.map((domainObject) => { + if (domainObject.userIds !== undefined) { + domainObject.removeUser(userId); + } + return domainObject; + }); + + await this.classesRepo.updateMany(updatedClasses); + + return updatedClasses.length; + } +} diff --git a/apps/server/src/modules/class/service/index.ts b/apps/server/src/modules/class/service/index.ts new file mode 100644 index 00000000000..dc17837c2c9 --- /dev/null +++ b/apps/server/src/modules/class/service/index.ts @@ -0,0 +1 @@ +export * from './class.service'; diff --git a/apps/server/src/modules/collaborative-storage/mapper/team.mapper.ts b/apps/server/src/modules/collaborative-storage/mapper/team.mapper.ts index e5b60a97915..28f58609bbb 100644 --- a/apps/server/src/modules/collaborative-storage/mapper/team.mapper.ts +++ b/apps/server/src/modules/collaborative-storage/mapper/team.mapper.ts @@ -1,4 +1,4 @@ -import { Team, TeamUser } from '@shared/domain'; +import { TeamEntity, TeamUserEntity } from '@shared/domain'; import { Injectable } from '@nestjs/common'; import { TeamDto, TeamUserDto } from '../services/dto/team.dto'; @@ -9,9 +9,9 @@ export class TeamMapper { * @param teamEntity The Entity * @return The Dto */ - public mapEntityToDto(teamEntity: Team): TeamDto { + public mapEntityToDto(teamEntity: TeamEntity): TeamDto { const teamUsers: TeamUserDto[] = teamEntity.teamUsers.map( - (teamUser: TeamUser) => + (teamUser: TeamUserEntity) => new TeamUserDto({ userId: teamUser.user.id, roleId: teamUser.role.id, 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 37a0889a730..1ed1aa3635c 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 @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { RoleName, Team } from '@shared/domain'; +import { RoleName, TeamEntity } from '@shared/domain'; import { CollaborativeStorageAdapter } from '@shared/infra/collaborative-storage'; import { TeamsRepo } from '@shared/repo'; import { setupEntities } from '@shared/testing'; @@ -26,7 +26,7 @@ describe('Collaborative Storage Service', () => { let mockId: string; let roleDto: RoleDto; - let team: Team; + let team: TeamEntity; beforeAll(async () => { module = await Test.createTestingModule({ diff --git a/apps/server/src/modules/files-storage-client/interfaces/files-storage-client-errors.ts b/apps/server/src/modules/files-storage-client/interfaces/files-storage-client-errors.ts deleted file mode 100644 index fb81d8cb5f7..00000000000 --- a/apps/server/src/modules/files-storage-client/interfaces/files-storage-client-errors.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ForbiddenException, InternalServerErrorException } from '@nestjs/common'; -import { ApiValidationError } from '@shared/common'; - -export type FileStorageErrors = ApiValidationError | ForbiddenException | InternalServerErrorException; - -export interface IFileStorageErrors extends Error { - status?: number; - message: never; - validationErrors?: []; -} diff --git a/apps/server/src/modules/files-storage-client/interfaces/index.ts b/apps/server/src/modules/files-storage-client/interfaces/index.ts index 85561cb17b6..3e7eb774e4d 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/index.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/index.ts @@ -2,5 +2,4 @@ export * from './copy-file-domain-object-props'; export * from './file-domain-object-props'; export * from './file-request-info'; export * from './files-storage-client-config'; -export * from './files-storage-client-errors'; export * from './types'; diff --git a/apps/server/src/modules/files-storage-client/mapper/error.mapper.spec.ts b/apps/server/src/modules/files-storage-client/mapper/error.mapper.spec.ts index 9b6cdd4a7ac..c3cf506e541 100644 --- a/apps/server/src/modules/files-storage-client/mapper/error.mapper.spec.ts +++ b/apps/server/src/modules/files-storage-client/mapper/error.mapper.spec.ts @@ -3,90 +3,50 @@ import { ConflictException, ForbiddenException, InternalServerErrorException, - ValidationError as IValidationError, } from '@nestjs/common'; -import { ApiValidationError } from '@shared/common'; +import { IError } from '@shared/infra/rabbitmq'; import _ from 'lodash'; -import { IFileStorageErrors } from '../interfaces'; import { ErrorMapper } from './error.mapper'; describe('ErrorMapper', () => { - const createApiValidationError = (): ApiValidationError => { - const constraints: IValidationError[] = []; - constraints.push( - { - property: 'propWithoutConstraint', - }, - { - property: 'propWithOneConstraing', - constraints: { - rulename: 'ruleDescription', - }, - }, - { - property: 'propWithMultipleCOnstraints', - constraints: { - rulename: 'ruleDescription', - secondrulename: 'secondRuleDescription', - }, - } - ); + describe('mapErrorToDomainError', () => { + it('Should map any 400 error to BadRequestException.', () => { + const errorText = 'BadRequestException ABC'; + const e = new BadRequestException(errorText); + const json = _.toPlainObject(e) as IError; - const apiValidationError = new ApiValidationError(constraints); + const result = ErrorMapper.mapRpcErrorResponseToDomainError(json); - return apiValidationError; - }; + expect(result).toStrictEqual(new BadRequestException(errorText)); + }); - describe('mapErrorToDomainError', () => { it('Should map 403 error response to ForbiddenException.', () => { const errorText = 'ForbiddenException ABC'; - const json = _.toPlainObject(new ForbiddenException(errorText)) as IFileStorageErrors; - const result = ErrorMapper.mapErrorToDomainError(json); + const rpcResponseError = _.toPlainObject(new ForbiddenException(errorText)) as IError; + + const result = ErrorMapper.mapRpcErrorResponseToDomainError(rpcResponseError); expect(result).toStrictEqual(new ForbiddenException(errorText)); }); it('Should map 500 error response to InternalServerErrorException.', () => { const errorText = 'InternalServerErrorException ABC'; - const json = _.toPlainObject(new InternalServerErrorException(errorText)) as IFileStorageErrors; + const json = _.toPlainObject(new InternalServerErrorException(errorText)) as IError; - const result = ErrorMapper.mapErrorToDomainError(json); + const result = ErrorMapper.mapRpcErrorResponseToDomainError(json); expect(result).toStrictEqual(new InternalServerErrorException(errorText)); }); it('Should map unknown error code to InternalServerErrorException.', () => { - const errorText = 'ForbiddenException ABC'; - const json = _.toPlainObject(new ConflictException(errorText)) as IFileStorageErrors; - const result = ErrorMapper.mapErrorToDomainError(json); + const errorText = 'Any error text'; + const json = _.toPlainObject(new ConflictException(errorText)) as IError; - expect(result).toStrictEqual(new InternalServerErrorException(errorText)); - }); + const result = ErrorMapper.mapRpcErrorResponseToDomainError(json); - it('Should map generic error to InternalServerErrorException.', () => { - const errorText = 'ABC'; - const error = new Error(errorText) as IFileStorageErrors; - const result = ErrorMapper.mapErrorToDomainError(error); - - expect(result).toStrictEqual(new InternalServerErrorException(errorText)); - }); - - it('Should map 400 api validation error response to ApiValidationError.', () => { - const apiValidationError = createApiValidationError(); - const json = _.toPlainObject(apiValidationError) as IFileStorageErrors; - - const result = ErrorMapper.mapErrorToDomainError(json); - - expect(result).toStrictEqual(new ApiValidationError()); - }); - - it('Should map any 400 error that is not an ApiValidationError to InternalServerErrorException.', () => { - const errorText = 'ForbiddenException ABC'; - const e = new BadRequestException(errorText); - const json = _.toPlainObject(e) as IFileStorageErrors; - const result = ErrorMapper.mapErrorToDomainError(json); - - expect(result).toStrictEqual(new BadRequestException(errorText)); + expect(result).toStrictEqual(new InternalServerErrorException('Internal Server Error Exception')); + // @ts-expect-error cause is always unknown + expect(result.cause?.message).toContain(errorText); }); }); }); diff --git a/apps/server/src/modules/files-storage-client/mapper/error.mapper.ts b/apps/server/src/modules/files-storage-client/mapper/error.mapper.ts index 5b3c6cde361..60f2e73795e 100644 --- a/apps/server/src/modules/files-storage-client/mapper/error.mapper.ts +++ b/apps/server/src/modules/files-storage-client/mapper/error.mapper.ts @@ -1,23 +1,20 @@ import { BadRequestException, ForbiddenException, InternalServerErrorException } from '@nestjs/common'; -import { ApiValidationError } from '@shared/common'; -import { FileStorageErrors, IFileStorageErrors } from '../interfaces'; +import { ErrorUtils } from '@src/core/error/utils'; +import { IError } from '@shared/infra/rabbitmq'; -export const isValidationError = (error: IFileStorageErrors): boolean => { - const checked = !!(error.validationErrors && error.validationErrors.length > 0); - - return checked; -}; export class ErrorMapper { - static mapErrorToDomainError(errorObj: IFileStorageErrors): FileStorageErrors { - let error: FileStorageErrors; - if (errorObj.status === 400 && isValidationError(errorObj)) { - error = new ApiValidationError(errorObj.validationErrors); - } else if (errorObj.status === 400 && !isValidationError(errorObj)) { + static mapRpcErrorResponseToDomainError( + errorObj: IError + ): BadRequestException | ForbiddenException | InternalServerErrorException { + let error: BadRequestException | ForbiddenException | InternalServerErrorException; + if (errorObj.status === 400) { error = new BadRequestException(errorObj.message); } else if (errorObj.status === 403) { error = new ForbiddenException(errorObj.message); + } else if (errorObj.status === 500) { + error = new InternalServerErrorException(errorObj.message); } else { - error = new InternalServerErrorException(errorObj); + error = new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(errorObj)); } return error; 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 b560b421881..926f860c736 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 @@ -1,7 +1,6 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { FileRecordParentType, FilesStorageEvents, FilesStorageExchange } from '@shared/infra/rabbitmq'; @@ -49,136 +48,208 @@ describe('FilesStorageProducer', () => { }); describe('copyFilesOfParent', () => { - const params = { - userId: new ObjectId().toHexString(), - source: { - parentType: FileRecordParentType.Task, - schoolId: '633d59e1c7a36834ad61e525', - parentId: '633d59e1c7a36834ad61e526', - }, - target: { - parentType: FileRecordParentType.Task, - schoolId: '633d59e1c7a36834ad61e525', - parentId: '633d59e1c7a36834ad61e527', - }, - }; - - it('should call all steps.', async () => { - amqpConnection.request.mockResolvedValue({ message: [] }); - - const res = await service.copyFilesOfParent(params); - - const expectedParams = { - exchange: FilesStorageExchange, - routingKey: FilesStorageEvents.COPY_FILES_OF_PARENT, - payload: params, - timeout, - }; + describe('when amqpConnection return with error in response', () => { + const setup = () => { + const params = { + userId: new ObjectId().toHexString(), + source: { + parentType: FileRecordParentType.Task, + schoolId: '633d59e1c7a36834ad61e525', + parentId: '633d59e1c7a36834ad61e526', + }, + target: { + parentType: FileRecordParentType.Task, + schoolId: '633d59e1c7a36834ad61e525', + parentId: '633d59e1c7a36834ad61e527', + }, + }; - expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); - expect(res).toEqual([]); - }); + amqpConnection.request.mockResolvedValueOnce({ error: new Error() }); + const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + return { params, spy }; + }; - it('should call error mapper if throw an error.', async () => { - amqpConnection.request.mockResolvedValue({ message: [] }); + it('should call error mapper and throw with error', async () => { + const { params, spy } = setup(); - await service.copyFilesOfParent(params); + await expect(service.copyFilesOfParent(params)).rejects.toThrowError(); + expect(spy).toBeCalled(); + }); + }); - const spy = jest - .spyOn(ErrorMapper, 'mapErrorToDomainError') - .mockImplementation(() => new InternalServerErrorException()); + describe('when valid params are passed and amqp connection return with a message', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + const parentIdSource = new ObjectId().toHexString(); + const parentIdTarget = new ObjectId().toHexString(); + + const params = { + userId, + source: { + parentType: FileRecordParentType.Task, + schoolId, + parentId: parentIdSource, + }, + target: { + parentType: FileRecordParentType.Task, + schoolId, + parentId: parentIdTarget, + }, + }; - amqpConnection.request.mockResolvedValue({ error: new Error() }); + const message = []; + amqpConnection.request.mockResolvedValueOnce({ message }); - await expect(service.copyFilesOfParent(params)).rejects.toThrowError(); - expect(spy).toBeCalled(); - spy.mockRestore(); - }); - }); + const expectedParams = { + exchange: FilesStorageExchange, + routingKey: FilesStorageEvents.COPY_FILES_OF_PARENT, + payload: params, + timeout, + }; - describe('listFilesOfParent', () => { - const param = { - parentType: FileRecordParentType.Task, - schoolId: 'school123', - parentId: '633d5acdda646580679dc448', - }; - - it('should call all steps.', async () => { - amqpConnection.request.mockResolvedValue({ message: [] }); - - const res = await service.listFilesOfParent(param); - - const expectedParams = { - exchange: FilesStorageExchange, - routingKey: FilesStorageEvents.LIST_FILES_OF_PARENT, - payload: param, - timeout, + return { params, expectedParams, message }; }; - expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); - expect(res).toEqual([]); - }); + it('should call the ampqConnection.', async () => { + const { params, expectedParams } = setup(); - it('should call error mapper if throw an error.', async () => { - const spy = jest - .spyOn(ErrorMapper, 'mapErrorToDomainError') - .mockImplementation(() => new InternalServerErrorException()); + await service.copyFilesOfParent(params); - amqpConnection.request.mockResolvedValue({ error: new Error() }); + expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); + }); + + it('should return the response message.', async () => { + const { params, message } = setup(); - await expect(service.listFilesOfParent(param)).rejects.toThrowError(); - expect(spy).toBeCalled(); + const res = await service.copyFilesOfParent(params); - spy.mockRestore(); + expect(res).toEqual(message); + }); }); }); - describe('deleteFilesOfParent', () => { - describe('when files are deleted successfully', () => { + describe('listFilesOfParent', () => { + describe('when valid parameter passed and amqpConnection return with error in response', () => { const setup = () => { + const schoolId = new ObjectId().toHexString(); const parentId = new ObjectId().toHexString(); - amqpConnection.request.mockResolvedValue({ message: [] }); - return { parentId }; + const param = { + parentType: FileRecordParentType.Task, + schoolId, + parentId, + }; + + amqpConnection.request.mockResolvedValue({ error: new Error() }); + + const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + return { param, spy }; }; - it('should call all steps.', async () => { - const { parentId } = setup(); + it('should call error mapper and throw with error', async () => { + const { param, spy } = setup(); - const res = await service.deleteFilesOfParent(parentId); + await expect(service.listFilesOfParent(param)).rejects.toThrowError(); + expect(spy).toBeCalled(); + }); + }); + + describe('when valid params are passed and ampq do return with message', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const parentId = new ObjectId().toHexString(); + + const param = { + parentType: FileRecordParentType.Task, + schoolId, + parentId, + }; const expectedParams = { exchange: FilesStorageExchange, - routingKey: FilesStorageEvents.DELETE_FILES_OF_PARENT, - payload: parentId, + routingKey: FilesStorageEvents.LIST_FILES_OF_PARENT, + payload: param, timeout, }; + const message = []; + + amqpConnection.request.mockResolvedValue({ message }); + + return { param, expectedParams, message }; + }; + + it('should call the ampqConnection.', async () => { + const { param, expectedParams } = setup(); + + await service.listFilesOfParent(param); + expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); - expect(res).toEqual([]); + }); + + it('should return the response message.', async () => { + const { param, message } = setup(); + + const res = await service.listFilesOfParent(param); + + expect(res).toEqual(message); }); }); + }); - describe('when error is thrown', () => { + describe('deleteFilesOfParent', () => { + describe('when valid parameter passed and amqpConnection return with error in response', () => { const setup = () => { const parentId = new ObjectId().toHexString(); - const spy = jest - .spyOn(ErrorMapper, 'mapErrorToDomainError') - .mockImplementation(() => new InternalServerErrorException()); - amqpConnection.request.mockResolvedValue({ error: new Error() }); + const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); return { parentId, spy }; }; - it('should call error mapper if throw an error.', async () => { + it('should call error mapper and throw with error', async () => { const { parentId, spy } = setup(); await expect(service.deleteFilesOfParent(parentId)).rejects.toThrowError(); expect(spy).toBeCalled(); + }); + }); + + describe('when valid parameter passed and amqpConnection return with message', () => { + const setup = () => { + const parentId = new ObjectId().toHexString(); + + const message = []; + amqpConnection.request.mockResolvedValue({ message }); + + const expectedParams = { + exchange: FilesStorageExchange, + routingKey: FilesStorageEvents.DELETE_FILES_OF_PARENT, + payload: parentId, + timeout, + }; + + return { parentId, message, expectedParams }; + }; + + it('should call the ampqConnection.', async () => { + const { parentId, expectedParams } = setup(); + + await service.deleteFilesOfParent(parentId); + + expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); + }); + + it('should return the response message.', async () => { + const { parentId, message } = setup(); + + const res = await service.deleteFilesOfParent(parentId); - spy.mockRestore(); + expect(res).toEqual(message); }); }); }); 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 ba1c6e2fe5c..afd4f6365e1 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 @@ -64,10 +64,12 @@ export class FilesStorageProducer { return response.message || []; } + // need to be fixed with https://ticketsystem.dbildungscloud.de/browse/BC-2984 + // mapRpcErrorResponseToDomainError should also removed with this ticket private checkError(response: RpcMessage) { const { error } = response; if (error) { - const domainError = ErrorMapper.mapErrorToDomainError(error); + const domainError = ErrorMapper.mapRpcErrorResponseToDomainError(error); throw domainError; } } 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 14497f1baf6..ff8f00e0a11 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 @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, courseFactory, @@ -16,7 +17,7 @@ import { } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { FilesStorageTestModule } from '@src/modules/files-storage'; +import { FILES_STORAGE_S3_CONNECTION, FilesStorageTestModule } from '@src/modules/files-storage'; import { CopyFileParams, CopyFilesOfParentParams, @@ -25,7 +26,6 @@ import { } from '@src/modules/files-storage/controller/dto'; import { Request } from 'express'; import request from 'supertest'; -import { S3ClientAdapter } from '../../client/s3-client.adapter'; import { FileRecordParentType } from '../../entity'; import { availableParentTypes } from './mocks'; @@ -90,7 +90,7 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(AntivirusService) .useValue(createMock()) - .overrideProvider(S3ClientAdapter) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ 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 6d99ca0b4b3..f210acb4e83 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 @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, fileRecordFactory, @@ -15,12 +16,11 @@ import { } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { FilesStorageTestModule } from '@src/modules/files-storage'; +import { FILES_STORAGE_S3_CONNECTION, FilesStorageTestModule } from '@src/modules/files-storage'; import { FileRecordListResponse, FileRecordResponse } from '@src/modules/files-storage/controller/dto'; import { Request } from 'express'; import request from 'supertest'; -import { S3ClientAdapter } from '../../client/s3-client.adapter'; -import { FileRecordParentType } from '../../entity'; +import { FileRecordParentType, PreviewStatus } from '../../entity'; import { availableParentTypes } from './mocks'; const baseRouteName = '/file/delete'; @@ -84,7 +84,7 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(AntivirusService) .useValue(createMock()) - .overrideProvider(S3ClientAdapter) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ @@ -203,6 +203,7 @@ describe(`${baseRouteName} (api)`, () => { deletedSince: expect.any(String), securityCheckStatus: 'pending', size: expect.any(Number), + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }); }); @@ -297,6 +298,7 @@ describe(`${baseRouteName} (api)`, () => { deletedSince: expect.any(String), securityCheckStatus: 'pending', size: expect.any(Number), + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }); }); 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 59d5bfd0d36..15edf45666a 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 @@ -5,17 +5,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { FilesStorageTestModule } from '@src/modules/files-storage'; +import { FILES_STORAGE_S3_CONNECTION, FilesStorageTestModule } from '@src/modules/files-storage'; import { FileRecordResponse } from '@src/modules/files-storage/controller/dto'; import { Request } from 'express'; -import { Readable } from 'node:stream'; import request from 'supertest'; -import { S3ClientAdapter } from '../../client/s3-client.adapter'; import { FileRecord } from '../../entity'; import { ErrorType } from '../../error'; +import { TestHelper } from '../../helper/test-helper'; import { availableParentTypes } from './mocks'; class API { @@ -79,21 +79,6 @@ class API { const createRndInt = (max) => Math.floor(Math.random() * max); -const createFileResponse = (contentRange?: string) => { - const text = 'testText'; - const readable = Readable.from(text); - - const fileResponse = { - data: readable, - contentType: 'text/plain', - contentLength: text.length, - contentRange, - etag: 'testTag', - }; - - return fileResponse; -}; - describe('files-storage controller (API)', () => { let module: TestingModule; let app: INestApplication; @@ -112,7 +97,7 @@ describe('files-storage controller (API)', () => { }) .overrideProvider(AntivirusService) .useValue(createMock()) - .overrideProvider(S3ClientAdapter) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ @@ -129,7 +114,7 @@ describe('files-storage controller (API)', () => { await a.listen(appPort); em = module.get(EntityManager); - s3ClientAdapter = module.get(S3ClientAdapter); + s3ClientAdapter = module.get(FILES_STORAGE_S3_CONNECTION); api = new API(app); }); @@ -282,7 +267,7 @@ describe('files-storage controller (API)', () => { describe(`with valid request data`, () => { describe(`with new file`, () => { beforeEach(async () => { - const expectedResponse = createFileResponse('bytes 0-3/4'); + const expectedResponse = TestHelper.createFile('bytes 0-3/4'); s3ClientAdapter.get.mockResolvedValueOnce(expectedResponse); const uploadResponse = await api.postUploadFile(`/file/upload/${validId}/schools/${validId}`); @@ -307,7 +292,7 @@ describe('files-storage controller (API)', () => { name: 'test (1).txt', parentId: validId, creatorId: currentUser.userId, - mimeType: 'text/plain', + mimeType: 'application/octet-stream', parentType: 'schools', securityCheckStatus: 'pending', }) @@ -317,8 +302,8 @@ describe('files-storage controller (API)', () => { describe(`with already existing file`, () => { beforeEach(async () => { - const expectedResponse1 = createFileResponse(); - const expectedResponse2 = createFileResponse(); + const expectedResponse1 = TestHelper.createFile('bytes 0-3/4'); + const expectedResponse2 = TestHelper.createFile('bytes 0-3/4'); s3ClientAdapter.get.mockResolvedValueOnce(expectedResponse1).mockResolvedValueOnce(expectedResponse2); @@ -373,7 +358,7 @@ describe('files-storage controller (API)', () => { describe(`with valid request data`, () => { const setup = async () => { const { result: uploadedFile } = await api.postUploadFile(`/file/upload/${validId}/schools/${validId}`); - const expectedResponse = createFileResponse('bytes 0-3/4'); + const expectedResponse = TestHelper.createFile('bytes 0-3/4'); s3ClientAdapter.get.mockResolvedValueOnce(expectedResponse); @@ -415,7 +400,7 @@ describe('files-storage controller (API)', () => { describe(`with valid request data`, () => { const setup = async () => { - const expectedResponse = createFileResponse('bytes 0-3/4'); + const expectedResponse = TestHelper.createFile('bytes 0-3/4'); s3ClientAdapter.get.mockResolvedValueOnce(expectedResponse); const { result } = await api.postUploadFile(`/file/upload/${validId}/schools/${validId}`); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts index 105b6193af4..6cc436fa7f6 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts @@ -17,7 +17,7 @@ import { FilesStorageTestModule } from '@src/modules/files-storage'; import { FileRecordListResponse, FileRecordResponse } from '@src/modules/files-storage/controller/dto'; import { Request } from 'express'; import request from 'supertest'; -import { FileRecordParentType } from '../../entity'; +import { FileRecordParentType, PreviewStatus } from '../../entity'; import { availableParentTypes } from './mocks'; const baseRouteName = '/file/list'; @@ -188,6 +188,7 @@ describe(`${baseRouteName} (api)`, () => { mimeType: 'application/octet-stream', securityCheckStatus: 'pending', size: expect.any(Number), + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }); }); 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 new file mode 100644 index 00000000000..65e0d8a58f5 --- /dev/null +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -0,0 +1,401 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } 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/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { FILES_STORAGE_S3_CONNECTION, FilesStorageTestModule } from '@src/modules/files-storage'; +import { FileRecordResponse } from '@src/modules/files-storage/controller/dto'; +import { Request } from 'express'; +import request from 'supertest'; +import { FileRecord, ScanStatus } from '../../entity'; +import { ErrorType } from '../../error'; +import { TestHelper } from '../../helper/test-helper'; +import { PreviewWidth } from '../../interface'; +import { PreviewOutputMimeTypes } from '../../interface/preview-output-mime-types.enum'; + +class API { + app: INestApplication; + + constructor(app: INestApplication) { + this.app = app; + } + + async postUploadFile(routeName: string, query?: string | Record) { + const response = await request(this.app.getHttpServer()) + .post(routeName) + .attach('file', Buffer.from('abcd'), 'test.png') + .set('connection', 'keep-alive') + .set('content-type', 'multipart/form-data; boundary=----WebKitFormBoundaryiBMuOC0HyZ3YnA20') + .query(query || {}); + + return { + result: response.body as FileRecordResponse, + error: response.body as ApiValidationError, + status: response.status, + }; + } + + async getPreview(routeName: string, query?: string | Record) { + const response = await request(this.app.getHttpServer()) + .get(routeName) + .query(query || {}); + + 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) + .set('Range', bytesRange) + .query(query || {}); + + return { + result: response.body as StreamableFile, + error: response.body as ApiValidationError, + status: response.status, + headers: response.headers as Record, + }; + } +} + +const createRndInt = (max) => Math.floor(Math.random() * max); + +const defaultQueryParameters = { + width: PreviewWidth.WIDTH_500, + outputFormat: PreviewOutputMimeTypes.IMAGE_WEBP, +}; + +describe('File Controller (API) - preview', () => { + let module: TestingModule; + let app: INestApplication; + let em: EntityManager; + let s3ClientAdapter: DeepMocked; + let currentUser: ICurrentUser; + let api: API; + let schoolId: EntityId; + let appPort: number; + let uploadPath: string; + + beforeAll(async () => { + appPort = 10000 + createRndInt(10000); + + module = await Test.createTestingModule({ + imports: [FilesStorageTestModule], + }) + .overrideProvider(AntivirusService) + .useValue(createMock()) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) + .useValue(createMock()) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .compile(); + + app = module.createNestApplication(); + const a = await app.init(); + await a.listen(appPort); + + em = module.get(EntityManager); + s3ClientAdapter = module.get(FILES_STORAGE_S3_CONNECTION); + api = new API(app); + }); + + afterAll(async () => { + await app.close(); + await module.close(); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + 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(); + schoolId = school.id; + currentUser = mapUserToCurrentUser(user); + uploadPath = `/file/upload/${schoolId}/schools/${schoolId}`; + }); + + const setScanStatus = async (fileRecordId: EntityId, status: ScanStatus) => { + const fileRecord = await em.findOneOrFail(FileRecord, fileRecordId); + fileRecord.securityCheck.status = status; + await em.flush(); + }; + + describe('preview', () => { + describe('with bad request data', () => { + describe('WHEN recordId is invalid', () => { + it('should return status 400', async () => { + const response = await api.getPreview('/file/preview/123/test.png', defaultQueryParameters); + + expect(response.error.validationErrors).toEqual([ + { + errors: ['fileRecordId must be a mongodb id'], + field: ['fileRecordId'], + }, + ]); + expect(response.status).toEqual(400); + }); + }); + + describe('WHEN width is other than PreviewWidth Enum', () => { + it('should return status 400', async () => { + const { result } = await api.postUploadFile(uploadPath); + const query = { + ...defaultQueryParameters, + width: 2000, + }; + + const response = await api.getPreview(`/file/preview/${result.id}/${result.name}`, query); + + expect(response.error.validationErrors).toEqual([ + { + errors: ['width must be one of the following values: 500'], + field: ['width'], + }, + ]); + expect(response.status).toEqual(400); + }); + }); + + describe('WHEN output format is wrong', () => { + it('should return status 400', async () => { + const { result } = await api.postUploadFile(uploadPath); + const query = { ...defaultQueryParameters, outputFormat: 'image/txt' }; + + const response = await api.getPreview(`/file/preview/${result.id}/${result.name}`, query); + + expect(response.error.validationErrors).toEqual([ + { + errors: ['outputFormat must be one of the following values: image/webp'], + field: ['outputFormat'], + }, + ]); + expect(response.status).toEqual(400); + }); + }); + + describe('WHEN file does not exist', () => { + it('should return status 404', async () => { + const { result } = await api.postUploadFile(uploadPath); + const wrongId = new ObjectId().toString(); + + const response = await api.getPreview(`/file/preview/${wrongId}/${result.name}`, defaultQueryParameters); + + expect(response.error.message).toEqual('The requested FileRecord: [object Object] has not been found.'); + expect(response.status).toEqual(404); + }); + }); + + describe('WHEN filename is wrong', () => { + const setup = async () => { + const { result } = await api.postUploadFile(uploadPath); + await setScanStatus(result.id, ScanStatus.VERIFIED); + const error = new NotFoundException(); + s3ClientAdapter.get.mockRejectedValueOnce(error); + + return { result }; + }; + + it('should return status 404', async () => { + const { result } = await setup(); + + const response = await api.getPreview(`/file/preview/${result.id}/wrong-name.txt`, defaultQueryParameters); + + expect(response.error.message).toEqual(ErrorType.FILE_NOT_FOUND); + expect(response.status).toEqual(404); + }); + }); + }); + + describe(`with valid request data`, () => { + describe('WHEN preview does already exist', () => { + describe('WHEN forceUpdate is undefined', () => { + const setup = async () => { + const { result: uploadedFile } = await api.postUploadFile(uploadPath); + await setScanStatus(uploadedFile.id, ScanStatus.VERIFIED); + + const previewFile = TestHelper.createFile('bytes 0-3/4'); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + return { uploadedFile, previewFile }; + }; + + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const buffer = Buffer.from('testText'); + + const response = await api.getPreview( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + defaultQueryParameters + ); + + expect(response.status).toEqual(200); + expect(response.result).toEqual(buffer); + }); + + it('should return status 206 and required headers for the successful partial file stream download', async () => { + const { uploadedFile } = await setup(); + + const response = await api.getPreviewBytesRange( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + 'bytes=0-', + defaultQueryParameters + ); + + expect(response.status).toEqual(206); + expect(response.headers['accept-ranges']).toMatch('bytes'); + expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + }); + }); + + describe('WHEN forceUpdate is false', () => { + const setup = async () => { + const { result: uploadedFile } = await api.postUploadFile(uploadPath); + await setScanStatus(uploadedFile.id, ScanStatus.VERIFIED); + + const previewFile = TestHelper.createFile('bytes 0-3/4'); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + 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); + }); + + 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'); + }); + }); + + describe('WHEN forceUpdate is true', () => { + const setup = async () => { + const { result: uploadedFile } = await api.postUploadFile(uploadPath); + await setScanStatus(uploadedFile.id, ScanStatus.VERIFIED); + + const originalFile = TestHelper.createFile(); + const previewFile = TestHelper.createFile('bytes 0-3/4'); + s3ClientAdapter.get.mockResolvedValueOnce(originalFile).mockResolvedValueOnce(previewFile); + + return { uploadedFile }; + }; + + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: true, + }; + + 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: true, + }; + + 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'); + }); + }); + }); + + describe('WHEN preview does not already exist', () => { + const setup = async () => { + const { result: uploadedFile } = await api.postUploadFile(uploadPath); + await setScanStatus(uploadedFile.id, ScanStatus.VERIFIED); + + const error = new NotFoundException(); + const originalFile = TestHelper.createFile(); + const previewFile = TestHelper.createFile('bytes 0-3/4'); + s3ClientAdapter.get + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(originalFile) + .mockResolvedValueOnce(previewFile); + + return { uploadedFile }; + }; + + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + + const response = await api.getPreview( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + defaultQueryParameters + ); + + 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 response = await api.getPreviewBytesRange( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + 'bytes=0-', + defaultQueryParameters + ); + + expect(response.status).toEqual(206); + expect(response.headers['accept-ranges']).toMatch('bytes'); + expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + }); + }); + }); + }); +}); 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 92c18165684..4997f647b80 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 @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, fileRecordFactory, @@ -15,12 +16,11 @@ import { } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { FilesStorageTestModule } from '@src/modules/files-storage'; +import { FILES_STORAGE_S3_CONNECTION, FilesStorageTestModule } from '@src/modules/files-storage'; import { FileRecordListResponse, FileRecordResponse } from '@src/modules/files-storage/controller/dto'; import { Request } from 'express'; import request from 'supertest'; -import { S3ClientAdapter } from '../../client/s3-client.adapter'; -import { FileRecordParentType } from '../../entity'; +import { FileRecordParentType, PreviewStatus } from '../../entity'; import { availableParentTypes } from './mocks'; const baseRouteName = '/file/restore'; @@ -109,7 +109,7 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(AntivirusService) .useValue(createMock()) - .overrideProvider(S3ClientAdapter) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ @@ -227,6 +227,7 @@ describe(`${baseRouteName} (api)`, () => { mimeType: 'text/plain', securityCheckStatus: 'pending', size: expect.any(Number), + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }); }); @@ -321,6 +322,7 @@ describe(`${baseRouteName} (api)`, () => { mimeType: 'text/plain', securityCheckStatus: 'pending', size: expect.any(Number), + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }); }); 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 8c9207041dc..d474685caf3 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,9 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StringToBoolean } from '@shared/controller'; import { EntityId } from '@shared/domain'; -import { Allow, IsEnum, IsMongoId, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { Allow, IsBoolean, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { FileRecordParentType } from '../../entity'; +import { PreviewOutputMimeTypes, PreviewWidth } from '../../interface'; export class FileRecordParams { @ApiProperty() @@ -12,7 +14,7 @@ export class FileRecordParams { @IsMongoId() parentId!: EntityId; - @ApiProperty({ enum: FileRecordParentType }) + @ApiProperty({ enum: FileRecordParentType, enumName: 'FileRecordParentType' }) @IsEnum(FileRecordParentType) parentType!: FileRecordParentType; } @@ -102,3 +104,23 @@ export class CopyFilesOfParentPayload { @ValidateNested() target!: FileRecordParams; } + +export class PreviewParams { + @ApiPropertyOptional({ enum: PreviewOutputMimeTypes, enumName: 'PreviewOutputMimeTypes' }) + @IsOptional() + @IsEnum(PreviewOutputMimeTypes) + outputFormat?: PreviewOutputMimeTypes; + + @ApiPropertyOptional({ enum: PreviewWidth, enumName: 'PreviewWidth' }) + @IsOptional() + @IsEnum(PreviewWidth) + width?: PreviewWidth; + + @IsOptional() + @IsBoolean() + @StringToBoolean() + @ApiPropertyOptional({ + description: 'If true, the preview will be generated again.', + }) + forceUpdate?: boolean; +} diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts index 970dda7ec9b..439bddeef73 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DecodeHtmlEntities, PaginationResponse } from '@shared/controller'; -import { FileRecord, FileRecordParentType, ScanStatus } from '../../entity'; +import { FileRecord, FileRecordParentType, PreviewStatus, ScanStatus } from '../../entity'; import { API_VERSION_PATH } from '../../files-storage.const'; export class FileRecordResponse { @@ -15,6 +15,7 @@ export class FileRecordResponse { this.mimeType = fileRecord.mimeType; this.parentType = fileRecord.parentType; this.deletedSince = fileRecord.deletedSince; + this.previewStatus = fileRecord.getPreviewStatus(); } @ApiProperty() @@ -30,7 +31,7 @@ export class FileRecordResponse { @ApiProperty() url: string; - @ApiProperty({ enum: ScanStatus }) + @ApiProperty({ enum: ScanStatus, enumName: 'FileRecordScanStatus' }) securityCheckStatus: ScanStatus; @ApiProperty() @@ -42,9 +43,12 @@ export class FileRecordResponse { @ApiProperty() mimeType: string; - @ApiProperty({ enum: FileRecordParentType }) + @ApiProperty({ enum: FileRecordParentType, enumName: 'FileRecordParentType' }) parentType: FileRecordParentType; + @ApiProperty({ enum: PreviewStatus, enumName: 'PreviewStatus' }) + previewStatus: PreviewStatus; + @ApiPropertyOptional() deletedSince?: Date; } 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 b976bd4ee6e..c1c17d685b4 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 @@ -6,6 +6,7 @@ import { Delete, ForbiddenException, Get, + Headers, HttpStatus, InternalServerErrorException, NotAcceptableException, @@ -17,14 +18,17 @@ import { Req, Res, StreamableFile, + UnprocessableEntityException, UseInterceptors, } from '@nestjs/common'; -import { ApiConsumes, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiConsumes, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError, RequestLoggingInterceptor } from '@shared/common'; import { PaginationParams } from '@shared/controller'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { Request, Response } from 'express'; +import { GetFileResponse } from '../interface'; +import { FilesStorageMapper } from '../mapper'; import { FileRecordMapper } from '../mapper/file-record.mapper'; import { FilesStorageUC } from '../uc'; import { @@ -38,6 +42,7 @@ import { FileRecordParams, FileRecordResponse, FileUrlParams, + PreviewParams, RenameFileParams, SingleFileParams, } from './dto'; @@ -96,44 +101,77 @@ export class FilesStorageController { @ApiResponse({ status: 404, type: NotFoundException }) @ApiResponse({ status: 406, type: NotAcceptableException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) + @ApiHeader({ name: 'Range', required: false }) @Get('/download/:fileRecordId/:fileName') async download( @Param() params: DownloadFileParams, @CurrentUser() currentUser: ICurrentUser, @Req() req: Request, - @Res({ passthrough: true }) response: Response + @Res({ passthrough: true }) response: Response, + @Headers('Range') bytesRange?: string ): Promise { - // Get Range HTTP header value to check if caller - // requested either partial or full data stream. - const bytesRange = req.header('Range'); + const fileResponse = await this.filesStorageUC.download(currentUser.userId, params, bytesRange); - // Call download method with either defined or undefined bytes range. - const res = await this.filesStorageUC.download(currentUser.userId, params, bytesRange); + const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange); - // Destroy the stream after it has been closed. - req.on('close', () => res.data.destroy()); + return streamableFile; + } + + @ApiOperation({ summary: 'Streamable download of a preview file.' }) + @ApiResponse({ status: 200, type: StreamableFile }) + @ApiResponse({ status: 206, type: StreamableFile }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @ApiResponse({ status: 422, type: UnprocessableEntityException }) + @ApiResponse({ status: 500, type: InternalServerErrorException }) + @ApiHeader({ name: 'Range', required: false }) + @Get('/preview/:fileRecordId/:fileName') + async downloadPreview( + @Param() params: DownloadFileParams, + @CurrentUser() currentUser: ICurrentUser, + @Query() previewParams: PreviewParams, + @Req() req: Request, + @Res({ passthrough: true }) response: Response, + @Headers('Range') bytesRange?: string + ): Promise { + const fileResponse = await this.filesStorageUC.downloadPreview( + currentUser.userId, + params, + previewParams, + bytesRange + ); + + const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange); + + return streamableFile; + } + + private streamFileToClient( + req: Request, + fileResponse: GetFileResponse, + httpResponse: Response, + bytesRange?: string + ): StreamableFile { + req.on('close', () => fileResponse.data.destroy()); // If bytes range has been defined, set Accept-Ranges and Content-Range HTTP headers // in a response and also set 206 Partial Content HTTP status code to inform the caller // about the partial data stream. Otherwise, just set a 200 OK HTTP status code. if (bytesRange) { - response.set({ + httpResponse.set({ 'Accept-Ranges': 'bytes', - 'Content-Range': res.contentRange, + 'Content-Range': fileResponse.contentRange, }); - response.status(HttpStatus.PARTIAL_CONTENT); + httpResponse.status(HttpStatus.PARTIAL_CONTENT); } else { - response.status(HttpStatus.OK); + httpResponse.status(HttpStatus.OK); } - // Return StreamableFile with stream data and options that will additionally set - // Content-Type, Content-Disposition and Content-Length headers in a response. - return new StreamableFile(res.data, { - type: res.contentType, - disposition: `inline; filename="${encodeURI(params.fileName)}"`, - length: res.contentLength, - }); + const streamableFile = FilesStorageMapper.mapToStreamableFile(fileResponse); + + return streamableFile; } @ApiOperation({ summary: 'Get a list of file meta data of a parent entityId.' }) diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts index 32bafce395d..df092c0a952 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts @@ -2,11 +2,13 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { ErrorType } from '../error'; +import { PreviewInputMimeTypes } from '../interface'; import { FileRecord, FileRecordParentType, FileSecurityCheck, IFileRecordProperties, + PreviewStatus, ScanStatus, } from './filerecord.entity'; @@ -303,6 +305,82 @@ describe('FileRecord Entity', () => { }); }); + describe('hasScanStatusError is called', () => { + describe('WHEN file record security status is ERROR', () => { + const setup = () => { + const fileRecord = fileRecordFactory.build(); + + fileRecord.securityCheck.status = ScanStatus.ERROR; + + return { fileRecord }; + }; + + it('should return true', () => { + const { fileRecord } = setup(); + + const result = fileRecord.hasScanStatusError(); + + expect(result).toBe(true); + }); + }); + + describe('WHEN file record security status is not ERROR', () => { + const setup = () => { + const fileRecord = fileRecordFactory.build(); + + fileRecord.securityCheck.status = ScanStatus.VERIFIED; + + return { fileRecord }; + }; + + it('should return false', () => { + const { fileRecord } = setup(); + + const result = fileRecord.isBlocked(); + + expect(result).toBe(false); + }); + }); + }); + + describe('hasScanStatusWontCheck is called', () => { + describe('WHEN file record security status is WONT_CHECK', () => { + const setup = () => { + const fileRecord = fileRecordFactory.build(); + + fileRecord.securityCheck.status = ScanStatus.WONT_CHECK; + + return { fileRecord }; + }; + + it('should return true', () => { + const { fileRecord } = setup(); + + const result = fileRecord.hasScanStatusWontCheck(); + + expect(result).toBe(true); + }); + }); + + describe('WHEN file record security status is not WONT_CHECK', () => { + const setup = () => { + const fileRecord = fileRecordFactory.build(); + + fileRecord.securityCheck.status = ScanStatus.VERIFIED; + + return { fileRecord }; + }; + + it('should return false', () => { + const { fileRecord } = setup(); + + const result = fileRecord.hasScanStatusWontCheck(); + + expect(result).toBe(false); + }); + }); + }); + describe('isPending is called', () => { describe('WHEN file record security status is PENDING', () => { const setup = () => { @@ -531,4 +609,178 @@ describe('FileRecord Entity', () => { }); }); }); + + describe('getPreviewStatus is called', () => { + describe('WHEN file record securityCheck status is PENDING', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.PENDING; + + return { fileRecord }; + }; + + it('should return AWAITING_SCAN_STATUS', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.AWAITING_SCAN_STATUS); + }); + }); + + describe('WHEN file record securityCheck status is PENDING and mime type is not previewable', () => { + const setup = () => { + const mimeType = 'application/octet-stream'; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.PENDING; + + return { fileRecord }; + }; + + it('should return AWAITING_SCAN_STATUS', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE); + }); + }); + + describe('WHEN file record securityCheck status is VERIFIED', () => { + describe('MIMETYPE is supported', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.VERIFIED; + + return { fileRecord }; + }; + + it('should return PREVIEW_POSSIBLE', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_POSSIBLE); + }); + }); + + describe('MIMETYPE is not supported', () => { + const setup = () => { + const fileRecord = fileRecordFactory.build(); + + fileRecord.securityCheck.status = ScanStatus.VERIFIED; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE); + }); + }); + }); + + describe('WHEN file record securityCheck status is ERROR', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.ERROR; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR); + }); + }); + + describe('WHEN file record securityCheck status is BLOCKED', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.BLOCKED; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED); + }); + }); + + describe('WHEN file record securityCheck status is BLOCKED and mime type is not previewable', () => { + const setup = () => { + const mimeType = 'application/octet-stream'; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.BLOCKED; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED); + }); + }); + + describe('WHEN file record securityCheck status is WONT_CHECK', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = ScanStatus.WONT_CHECK; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_SCAN_STATUS_WONT_CHECK', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_WONT_CHECK); + }); + }); + + describe('WHEN file record securityCheck status is of other than ScanStatus Enum value', () => { + const setup = () => { + const mimeType = PreviewInputMimeTypes.IMAGE_JPEG; + const fileRecord = fileRecordFactory.build({ mimeType }); + + fileRecord.securityCheck.status = 'OTHER_STATUS' as ScanStatus; + + return { fileRecord }; + }; + + it('should return PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR', () => { + const { fileRecord } = setup(); + + const result = fileRecord.getPreviewStatus(); + + expect(result).toEqual(PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR); + }); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts index b72dd73faba..799acbc0ab1 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -4,11 +4,13 @@ import { BadRequestException } from '@nestjs/common'; import { BaseEntityWithTimestamps, type EntityId } from '@shared/domain'; import { v4 as uuid } from 'uuid'; import { ErrorType } from '../error'; +import { PreviewInputMimeTypes } from '../interface/preview-input-mime-types.enum'; export enum ScanStatus { PENDING = 'pending', VERIFIED = 'verified', BLOCKED = 'blocked', + WONT_CHECK = 'wont_check', ERROR = 'error', } @@ -21,6 +23,16 @@ export enum FileRecordParentType { 'Submission' = 'submissions', 'BoardNode' = 'boardnodes', } + +export enum PreviewStatus { + PREVIEW_POSSIBLE = 'preview_possible', + AWAITING_SCAN_STATUS = 'awaiting_scan_status', + PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR = 'preview_not_possible_scan_status_error', + PREVIEW_NOT_POSSIBLE_SCAN_STATUS_WONT_CHECK = 'preview_not_possible_scan_status_wont_check', + PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED = 'preview_not_possible_scan_status_blocked', + PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE = 'preview_not_possible_wrong_mime_type', +} + export interface IFileSecurityCheckProperties { status?: ScanStatus; reason?: string; @@ -218,6 +230,18 @@ export class FileRecord extends BaseEntityWithTimestamps { return isBlocked; } + public hasScanStatusError(): boolean { + const hasError = this.securityCheck.status === ScanStatus.ERROR; + + return hasError; + } + + public hasScanStatusWontCheck(): boolean { + const hasWontCheckStatus = this.securityCheck.status === ScanStatus.WONT_CHECK; + + return hasWontCheckStatus; + } + public isPending(): boolean { const isPending = this.securityCheck.status === ScanStatus.PENDING; @@ -239,4 +263,28 @@ export class FileRecord extends BaseEntityWithTimestamps { public getSchoolId(): EntityId { return this.schoolId; } + + public getPreviewStatus(): PreviewStatus { + if (this.isBlocked()) { + return PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED; + } + + if (!Object.values(PreviewInputMimeTypes).includes(this.mimeType)) { + return PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE; + } + + if (this.isVerified()) { + return PreviewStatus.PREVIEW_POSSIBLE; + } + + if (this.isPending()) { + return PreviewStatus.AWAITING_SCAN_STATUS; + } + + if (this.hasScanStatusWontCheck()) { + return PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_WONT_CHECK; + } + + return PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_ERROR; + } } diff --git a/apps/server/src/modules/files-storage/error/error-status.enum.ts b/apps/server/src/modules/files-storage/error/error-status.enum.ts index 0abefcb41b5..9b11c1f3c6c 100644 --- a/apps/server/src/modules/files-storage/error/error-status.enum.ts +++ b/apps/server/src/modules/files-storage/error/error-status.enum.ts @@ -5,4 +5,5 @@ export enum ErrorType { FILE_NAME_EMPTY = 'FILE_NAME_EMPTY', COULD_NOT_CREATE_PATH = 'COULD_NOT_CREATE_PATH', FILE_TOO_BIG = 'FILE_TOO_BIG', + PREVIEW_NOT_POSSIBLE = 'PREVIEW_NOT_POSSIBLE', } 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 37000772dfd..f3532b157e8 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -1,15 +1,18 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { S3Config } from '@shared/infra/s3-client'; import { ICoreModuleConfig } from '@src/core'; -import { S3Config } from './interface'; +export const FILES_STORAGE_S3_CONNECTION = 'FILES_STORAGE_S3_CONNECTION'; export interface IFileStorageConfig extends ICoreModuleConfig { MAX_FILE_SIZE: number; + MAX_SECURITY_CHECK_FILE_SIZE: number; } const fileStorageConfig: IFileStorageConfig = { INCOMING_REQUEST_TIMEOUT: Configuration.get('FILES_STORAGE__INCOMING_REQUEST_TIMEOUT') as number, INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, MAX_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, + MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILE_SECURITY_CHECK_MAX_FILE_SIZE') as number, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, }; @@ -17,6 +20,7 @@ const fileStorageConfig: IFileStorageConfig = { // config/development.json for development // config/test.json for tests export const s3Config: S3Config = { + connectionName: FILES_STORAGE_S3_CONNECTION, endpoint: Configuration.get('FILES_STORAGE__S3_ENDPOINT') as string, region: Configuration.get('FILES_STORAGE__S3_REGION') as string, bucket: Configuration.get('FILES_STORAGE__S3_BUCKET') as string, 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 70387143894..b33abd62b91 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -1,4 +1,3 @@ -import { S3Client } from '@aws-sdk/client-s3'; import { Configuration } from '@hpi-schul-cloud/commons'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; @@ -7,14 +6,14 @@ import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain'; import { AntivirusModule } from '@shared/infra/antivirus/antivirus.module'; import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq/rabbitmq.module'; -import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; +import { S3ClientModule } from '@shared/infra/s3-client'; +import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; -import { S3ClientAdapter } from './client/s3-client.adapter'; import { FileRecord, FileSecurityCheck } from './entity'; import { config, s3Config } from './files-storage.config'; -import { S3Config } from './interface/config'; import { FileRecordRepo } from './repo'; import { FilesStorageService } from './service/files-storage.service'; +import { PreviewService } from './service/preview.service'; const imports = [ LoggerModule, @@ -25,31 +24,9 @@ const imports = [ exchange: Configuration.get('ANTIVIRUS_EXCHANGE') as string, routingKey: Configuration.get('ANTIVIRUS_ROUTING_KEY') as string, }), + S3ClientModule.register([s3Config]), ]; -const providers = [ - FilesStorageService, - { - provide: 'S3_Client', - useFactory: (configProvider: S3Config) => - new S3Client({ - region: configProvider.region, - credentials: { - accessKeyId: configProvider.accessKeyId, - secretAccessKey: configProvider.secretAccessKey, - }, - endpoint: configProvider.endpoint, - forcePathStyle: true, - tls: true, - }), - inject: ['S3_Config'], - }, - { - provide: 'S3_Config', - useValue: s3Config, - }, - S3ClientAdapter, - FileRecordRepo, -]; +const providers = [FilesStorageService, PreviewService, FileRecordRepo]; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -74,6 +51,6 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }), ], providers, - exports: [FilesStorageService], + exports: [FilesStorageService, PreviewService], }) export class FilesStorageModule {} diff --git a/apps/server/src/modules/files-storage/helper/file-name.spec.ts b/apps/server/src/modules/files-storage/helper/file-name.spec.ts index eb099b53f20..eb47fd9413e 100644 --- a/apps/server/src/modules/files-storage/helper/file-name.spec.ts +++ b/apps/server/src/modules/files-storage/helper/file-name.spec.ts @@ -1,8 +1,10 @@ import { EntityId } from '@shared/domain'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { ObjectId } from 'bson'; -import { hasDuplicateName, resolveFileNameDuplicates } from '.'; +import crypto from 'crypto'; +import { createPreviewNameHash, hasDuplicateName, resolveFileNameDuplicates } from '.'; import { FileRecord } from '../entity'; +import { PreviewOutputMimeTypes } from '../interface/preview-output-mime-types.enum'; describe('File Name Helper', () => { const setupFileRecords = () => { @@ -233,4 +235,36 @@ describe('File Name Helper', () => { }); }); }); + + describe('createPreviewNameHash is called', () => { + describe('when preview params are set', () => { + it('should return hash', () => { + const fileRecordId = new ObjectId().toHexString(); + const width = 100; + const outputFormat = PreviewOutputMimeTypes.IMAGE_WEBP; + const previewParams = { + width, + outputFormat, + }; + const fileParamsString = `${fileRecordId}${width}${outputFormat}`; + const hash = crypto.createHash('md5').update(fileParamsString).digest('hex'); + + const result = createPreviewNameHash(fileRecordId, previewParams); + + expect(result).toBe(hash); + }); + }); + + describe('when preview params are not set', () => { + it('should return hash', () => { + const fileRecordId = new ObjectId().toHexString(); + const fileParamsString = `${fileRecordId}${PreviewOutputMimeTypes.IMAGE_WEBP}`; + const hash = crypto.createHash('md5').update(fileParamsString).digest('hex'); + + const result = createPreviewNameHash(fileRecordId, { outputFormat: PreviewOutputMimeTypes.IMAGE_WEBP }); + + expect(result).toBe(hash); + }); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/helper/file-name.ts b/apps/server/src/modules/files-storage/helper/file-name.ts index 1ab3c0192b1..91e84b82c18 100644 --- a/apps/server/src/modules/files-storage/helper/file-name.ts +++ b/apps/server/src/modules/files-storage/helper/file-name.ts @@ -1,4 +1,7 @@ +import { EntityId } from '@shared/domain'; +import crypto from 'crypto'; import path from 'path'; +import { PreviewParams } from '../controller/dto'; import { FileRecord } from '../entity'; export function hasDuplicateName(fileRecords: FileRecord[], name: string): FileRecord | undefined { @@ -19,3 +22,12 @@ export function resolveFileNameDuplicates(filename: string, fileRecords: FileRec return newFilename; } + +export function createPreviewNameHash(fileRecordId: EntityId, previewParams: PreviewParams): string { + const width = previewParams.width ?? ''; + const format = previewParams.outputFormat ?? ''; + const fileParamsString = `${fileRecordId}${width}${format}`; + const hash = crypto.createHash('md5').update(fileParamsString).digest('hex'); + + return hash; +} diff --git a/apps/server/src/modules/files-storage/helper/path.spec.ts b/apps/server/src/modules/files-storage/helper/path.spec.ts index cca419beb4e..1e413a80158 100644 --- a/apps/server/src/modules/files-storage/helper/path.spec.ts +++ b/apps/server/src/modules/files-storage/helper/path.spec.ts @@ -1,7 +1,7 @@ import { EntityId } from '@shared/domain'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { ObjectId } from 'bson'; -import { createICopyFiles, createPath, getPaths } from '.'; +import { createCopyFiles, createPath, createPreviewDirectoryPath, createPreviewFilePath, getPaths } from '.'; import { FileRecord } from '../entity'; import { ErrorType } from '../error'; @@ -48,6 +48,20 @@ describe('Path Helper', () => { }); }); + describe('createPreviewFilePath', () => { + it('should create path', () => { + const path = createPreviewFilePath('schoolId', 'previewId', 'fileRecordId'); + expect(path).toBe('previews/schoolId/fileRecordId/previewId'); + }); + }); + + describe('createPreviewDirectoryPath', () => { + it('should create path', () => { + const path = createPreviewDirectoryPath('schoolId', 'fileRecordId'); + expect(path).toBe('previews/schoolId/fileRecordId'); + }); + }); + describe('getPaths', () => { const setup = () => { return { fileRecords: setupFileRecords() }; @@ -87,7 +101,7 @@ describe('Path Helper', () => { targetPath: createPath(targetFile.schoolId, targetFile.id), }; - const result = createICopyFiles(sourceFile, targetFile); + const result = createCopyFiles(sourceFile, targetFile); expect(result).toEqual(expectedICopyFiles); }); diff --git a/apps/server/src/modules/files-storage/helper/path.ts b/apps/server/src/modules/files-storage/helper/path.ts index da3b5d336ba..3ae81aef62d 100644 --- a/apps/server/src/modules/files-storage/helper/path.ts +++ b/apps/server/src/modules/files-storage/helper/path.ts @@ -1,7 +1,7 @@ import { EntityId } from '@shared/domain'; +import { CopyFiles } from '@shared/infra/s3-client'; import { FileRecord } from '../entity'; import { ErrorType } from '../error'; -import { ICopyFiles } from '../interface'; export function createPath(schoolId: EntityId, fileRecordId: EntityId): string { if (!schoolId || !fileRecordId) { @@ -13,17 +13,30 @@ export function createPath(schoolId: EntityId, fileRecordId: EntityId): string { return path; } +export function createPreviewDirectoryPath(schoolId: EntityId, sourceFileRecordId: EntityId): string { + const path = ['previews', schoolId, sourceFileRecordId].join('/'); + + return path; +} + +export function createPreviewFilePath(schoolId: EntityId, hash: string, sourceFileRecordId: EntityId): string { + const folderPath = createPreviewDirectoryPath(schoolId, sourceFileRecordId); + const filePath = [folderPath, hash].join('/'); + + return filePath; +} + export function getPaths(fileRecords: FileRecord[]): string[] { const paths = fileRecords.map((fileRecord) => createPath(fileRecord.getSchoolId(), fileRecord.id)); return paths; } -export function createICopyFiles(sourceFile: FileRecord, targetFile: FileRecord): ICopyFiles { - const iCopyFiles = { +export function createCopyFiles(sourceFile: FileRecord, targetFile: FileRecord): CopyFiles { + const copyFiles = { sourcePath: createPath(sourceFile.getSchoolId(), sourceFile.id), targetPath: createPath(targetFile.getSchoolId(), targetFile.id), }; - return iCopyFiles; + return copyFiles; } diff --git a/apps/server/src/modules/files-storage/helper/test-helper.ts b/apps/server/src/modules/files-storage/helper/test-helper.ts new file mode 100644 index 00000000000..a66bec17de2 --- /dev/null +++ b/apps/server/src/modules/files-storage/helper/test-helper.ts @@ -0,0 +1,28 @@ +import { GetFile } from '@shared/infra/s3-client'; +import { Readable } from 'stream'; +import { GetFileResponse } from '../interface'; + +export class TestHelper { + public static createFile = (contentRange?: string): GetFile => { + const text = 'testText'; + const readable = Readable.from(text); + + const fileResponse = { + data: readable, + contentType: 'image/webp', + contentLength: text.length, + contentRange, + etag: 'testTag', + }; + + return fileResponse; + }; + + public static createFileResponse = (contentRange?: string): GetFileResponse => { + const name = 'testName'; + const file = this.createFile(contentRange); + const fileResponse = { ...file, name }; + + return fileResponse; + }; +} diff --git a/apps/server/src/modules/files-storage/interface/config.ts b/apps/server/src/modules/files-storage/interface/config.ts deleted file mode 100644 index 93577407903..00000000000 --- a/apps/server/src/modules/files-storage/interface/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface S3Config { - endpoint: string; - region: string; - bucket: string; - accessKeyId: string; - secretAccessKey: string; -} diff --git a/apps/server/src/modules/files-storage/interface/index.ts b/apps/server/src/modules/files-storage/interface/index.ts index ba064f58283..4938ae20233 100644 --- a/apps/server/src/modules/files-storage/interface/index.ts +++ b/apps/server/src/modules/files-storage/interface/index.ts @@ -1,2 +1,4 @@ -export * from './config'; -export * from './storage-client'; +export * from './interfaces'; +export * from './preview-input-mime-types.enum'; +export * from './preview-output-mime-types.enum'; +export * from './preview-width.enum'; diff --git a/apps/server/src/modules/files-storage/interface/interfaces.ts b/apps/server/src/modules/files-storage/interface/interfaces.ts new file mode 100644 index 00000000000..047c943e55a --- /dev/null +++ b/apps/server/src/modules/files-storage/interface/interfaces.ts @@ -0,0 +1,21 @@ +import { Readable } from 'stream'; +import type { DownloadFileParams, PreviewParams } from '../controller/dto'; +import { FileRecord } from '../entity'; + +export interface GetFileResponse { + data: Readable; + etag?: string; + contentType?: string; + contentLength?: number; + contentRange?: string; + name: string; +} + +export interface PreviewFileParams { + fileRecord: FileRecord; + downloadParams: DownloadFileParams; + previewParams: PreviewParams; + hash: string; + filePath: string; + bytesRange?: string; +} diff --git a/apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts b/apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts new file mode 100644 index 00000000000..495096c8359 --- /dev/null +++ b/apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts @@ -0,0 +1,11 @@ +export enum PreviewInputMimeTypes { + IMAGE_BMP = 'image/bmp', + IMAGE_GIF = 'image/gif', + IMAGE_JPEG = 'image/jpeg', + IMAGE_PNG = 'image/png', + IMAGE_SVG = 'image/svg+xml', + IMAGE_HEIC = 'image/heic', + IMAGE_HEIF = 'image/heif', + IMAGE_TIFF = 'image/tiff', + IMAGE_WEBP = 'image/webp', +} diff --git a/apps/server/src/modules/files-storage/interface/preview-output-mime-types.enum.ts b/apps/server/src/modules/files-storage/interface/preview-output-mime-types.enum.ts new file mode 100644 index 00000000000..aabd34df3f2 --- /dev/null +++ b/apps/server/src/modules/files-storage/interface/preview-output-mime-types.enum.ts @@ -0,0 +1,3 @@ +export enum PreviewOutputMimeTypes { + IMAGE_WEBP = 'image/webp', +} diff --git a/apps/server/src/modules/files-storage/interface/preview-width.enum.ts b/apps/server/src/modules/files-storage/interface/preview-width.enum.ts new file mode 100644 index 00000000000..7c106ed5b11 --- /dev/null +++ b/apps/server/src/modules/files-storage/interface/preview-width.enum.ts @@ -0,0 +1,3 @@ +export enum PreviewWidth { + WIDTH_500 = 500, +} diff --git a/apps/server/src/modules/files-storage/interface/storage-client.ts b/apps/server/src/modules/files-storage/interface/storage-client.ts index 0d0d40df301..0a44c7e80b9 100644 --- a/apps/server/src/modules/files-storage/interface/storage-client.ts +++ b/apps/server/src/modules/files-storage/interface/storage-client.ts @@ -1,7 +1,7 @@ import internal from 'stream'; import { FileDto } from '../dto'; -export interface IGetFileResponse { +export interface IGetFile { data: internal.Readable; contentType: string | undefined; contentLength: number | undefined; @@ -9,11 +9,22 @@ export interface IGetFileResponse { etag: string | undefined; } +export interface IGetFileResponse extends IGetFile { + name: string; +} + export interface ICopyFiles { sourcePath: string; targetPath: string; } +export interface IListFiles { + path: string; + maxKeys?: number; + nextMarker?: string; + files?: string[]; +} + export interface IStorageClient { create(path: string, file: FileDto): unknown; diff --git a/apps/server/src/modules/files-storage/mapper/file-dto.builder.ts b/apps/server/src/modules/files-storage/mapper/file-dto.builder.ts index 3d195ab7012..1e402cce85c 100644 --- a/apps/server/src/modules/files-storage/mapper/file-dto.builder.ts +++ b/apps/server/src/modules/files-storage/mapper/file-dto.builder.ts @@ -4,7 +4,7 @@ import { Readable } from 'stream'; import { FileDto } from '../dto/file.dto'; export class FileDtoBuilder { - private static build(name: string, data: Readable, mimeType: string): FileDto { + public static build(name: string, data: Readable, mimeType: string): FileDto { const file = new FileDto({ name, data, mimeType }); return file; @@ -17,7 +17,7 @@ export class FileDtoBuilder { } public static buildFromAxiosResponse(name: string, response: AxiosResponse): FileDto { - const mimeType = response.headers['content-type']; + const mimeType = response.headers['Content-Type']?.toString() || 'application/octet-stream'; const file = FileDtoBuilder.build(name, response.data, mimeType); return file; diff --git a/apps/server/src/modules/files-storage/mapper/file-response.builder.spec.ts b/apps/server/src/modules/files-storage/mapper/file-response.builder.spec.ts new file mode 100644 index 00000000000..5fb5e462a17 --- /dev/null +++ b/apps/server/src/modules/files-storage/mapper/file-response.builder.spec.ts @@ -0,0 +1,30 @@ +import { Readable } from 'stream'; +import { FileResponseBuilder } from './file-response.builder'; + +describe('File Response Builder', () => { + describe('build is called', () => { + const setup = () => { + const text = 'testText'; + const readable = Readable.from(text); + const name = 'testName'; + const file = { + data: readable, + contentType: 'text/plain', + contentLength: text.length, + contentRange: 'range', + etag: 'testTag', + }; + const expectedResponse = { ...file, name }; + + return { file, name, expectedResponse }; + }; + + it('should return copy file response', () => { + const { file, name, expectedResponse } = setup(); + + const result = FileResponseBuilder.build(file, name); + + expect(result).toEqual(expectedResponse); + }); + }); +}); 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 new file mode 100644 index 00000000000..02344e4b3cb --- /dev/null +++ b/apps/server/src/modules/files-storage/mapper/file-response.builder.ts @@ -0,0 +1,10 @@ +import { GetFile } from '@shared/infra/s3-client'; +import { GetFileResponse } from '../interface'; + +export class FileResponseBuilder { + public static build(file: GetFile, name: string): GetFileResponse { + const fileResponse = { ...file, data: file.data, name }; + + return fileResponse; + } +} diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts index ad52bde3c28..3165ec49021 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts @@ -8,7 +8,7 @@ import { FileRecordResponse, SingleFileParams, } from '../controller/dto'; -import { FileRecord, FileRecordParentType } from '../entity'; +import { FileRecord, FileRecordParentType, PreviewStatus } from '../entity'; import { FilesStorageMapper } from './files-storage.mapper'; describe('FilesStorageMapper', () => { @@ -115,6 +115,7 @@ describe('FilesStorageMapper', () => { mimeType: fileRecord.mimeType, parentType: fileRecord.parentType, deletedSince: fileRecord.deletedSince, + previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, }; expect(result).toEqual(expectedFileRecordResponse); diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts index bb9002d0122..3d298cd3b2e 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts @@ -1,4 +1,4 @@ -import { NotImplementedException } from '@nestjs/common'; +import { NotImplementedException, StreamableFile } from '@nestjs/common'; import { AuthorizableReferenceType } from '@src/modules/authorization'; import { plainToClass } from 'class-transformer'; import { @@ -9,6 +9,7 @@ import { SingleFileParams, } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; +import { GetFileResponse } from '../interface'; export class FilesStorageMapper { static mapToAllowedAuthorizationEntityType(type: FileRecordParentType): AuthorizableReferenceType { @@ -60,4 +61,14 @@ export class FilesStorageMapper { const response = new FileRecordListResponse(responseFileRecords, total, skip, limit); return response; } + + static mapToStreamableFile(fileResponse: GetFileResponse): StreamableFile { + const streamableFile = new StreamableFile(fileResponse.data, { + type: fileResponse.contentType, + disposition: `inline; filename="${encodeURI(fileResponse.name)}"`, + length: fileResponse.contentLength, + }); + + return streamableFile; + } } diff --git a/apps/server/src/modules/files-storage/mapper/index.ts b/apps/server/src/modules/files-storage/mapper/index.ts index f5853645ad4..556e7508929 100644 --- a/apps/server/src/modules/files-storage/mapper/index.ts +++ b/apps/server/src/modules/files-storage/mapper/index.ts @@ -1,4 +1,5 @@ export * from './copy-file-response.builder'; export * from './file-dto.builder'; export * from './file-record.mapper'; +export * from './file-response.builder'; export * from './files-storage.mapper'; diff --git a/apps/server/src/modules/files-storage/repo/filerecord-scope.ts b/apps/server/src/modules/files-storage/repo/filerecord-scope.ts index 080b841b6dc..e335e99a300 100644 --- a/apps/server/src/modules/files-storage/repo/filerecord-scope.ts +++ b/apps/server/src/modules/files-storage/repo/filerecord-scope.ts @@ -1,5 +1,5 @@ -import { EntityId } from '@shared/domain'; import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain'; import { Scope } from '@shared/repo'; import { FileRecord } from '../entity'; 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 6a677b5c825..3605ae74080 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 @@ -3,12 +3,13 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType, ScanStatus } from '../entity'; -import { createICopyFiles } from '../helper'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; +import { createCopyFiles } from '../helper'; import { CopyFileResponseBuilder } from '../mapper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -46,7 +47,7 @@ describe('FilesStorageService copy methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { @@ -69,7 +70,7 @@ describe('FilesStorageService copy methods', () => { }).compile(); service = module.get(FilesStorageService); - storageClient = module.get(S3ClientAdapter); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); fileRecordRepo = module.get(FileRecordRepo); antivirusService = module.get(AntivirusService); }); @@ -213,7 +214,7 @@ describe('FilesStorageService copy methods', () => { await service.copy(userId, [sourceFile], params); - const expectedParams = createICopyFiles(sourceFile, targetFile); + const expectedParams = createCopyFiles(sourceFile, targetFile); expect(storageClient.copy).toBeCalledWith([expectedParams]); }); @@ -365,9 +366,7 @@ describe('FilesStorageService copy methods', () => { const expectedResponse = [{ sourceId: sourceFile.id, name: sourceFile.name }]; const error = new Error('test'); - antivirusService.send.mockImplementation(() => { - throw error; - }); + antivirusService.send.mockRejectedValueOnce(error); return { sourceFile, targetFile, userId, params, error, expectedResponse }; }; 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 249a3f444d9..627f35d91da 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 @@ -4,11 +4,12 @@ import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { getPaths } from '../helper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -45,7 +46,7 @@ describe('FilesStorageService delete methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { @@ -68,7 +69,7 @@ describe('FilesStorageService delete methods', () => { }).compile(); service = module.get(FilesStorageService); - storageClient = module.get(S3ClientAdapter); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); fileRecordRepo = module.get(FileRecordRepo); }); 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 0c2617a3cbc..a578bca51a1 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 @@ -4,14 +4,15 @@ import { NotAcceptableException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType, ScanStatus } from '../entity'; import { ErrorType } from '../error'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { createPath } from '../helper'; -import { IGetFileResponse } from '../interface'; +import { FileResponseBuilder } from '../mapper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -46,7 +47,7 @@ describe('FilesStorageService download methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { @@ -69,7 +70,7 @@ describe('FilesStorageService download methods', () => { }).compile(); service = module.get(FilesStorageService); - storageClient = module.get(S3ClientAdapter); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); }); beforeEach(() => { @@ -100,7 +101,8 @@ describe('FilesStorageService download methods', () => { fileName: fileRecord.name, }; - const expectedResponse = createMock(); + const fileResponse = createMock(); + const expectedResponse = FileResponseBuilder.build(fileResponse, fileRecord.getName()); spy = jest.spyOn(service, 'downloadFile').mockResolvedValueOnce(expectedResponse); @@ -112,7 +114,7 @@ describe('FilesStorageService download methods', () => { await service.download(fileRecord, params); - expect(service.downloadFile).toHaveBeenCalledWith(fileRecord.schoolId, fileRecord.id, undefined); + expect(service.downloadFile).toHaveBeenCalledWith(fileRecord, undefined); }); it('returns correct response', async () => { @@ -202,9 +204,10 @@ describe('FilesStorageService download methods', () => { const { fileRecords } = buildFileRecordsWithParams(); const fileRecord = fileRecords[0]; - const expectedResponse = createMock(); + const fileResponse = createMock(); - storageClient.get.mockResolvedValueOnce(expectedResponse); + storageClient.get.mockResolvedValueOnce(fileResponse); + const expectedResponse = FileResponseBuilder.build(fileResponse, fileRecord.getName()); return { fileRecord, expectedResponse }; }; @@ -214,7 +217,7 @@ describe('FilesStorageService download methods', () => { const path = createPath(fileRecord.schoolId, fileRecord.id); - await service.downloadFile(fileRecord.schoolId, fileRecord.id); + await service.downloadFile(fileRecord); expect(storageClient.get).toHaveBeenCalledWith(path, undefined); }); @@ -222,7 +225,7 @@ describe('FilesStorageService download methods', () => { it('returns correct response', async () => { const { fileRecord, expectedResponse } = setup(); - const response = await service.downloadFile(fileRecord.schoolId, fileRecord.id); + const response = await service.downloadFile(fileRecord); expect(response).toEqual(expectedResponse); }); @@ -242,7 +245,7 @@ describe('FilesStorageService download methods', () => { it('passes error', async () => { const { fileRecord, error } = setup(); - await expect(service.downloadFile(fileRecord.schoolId, fileRecord.id)).rejects.toThrowError(error); + await expect(service.downloadFile(fileRecord)).rejects.toThrowError(error); }); }); }); 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 596a143abe3..3565e5a328a 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 @@ -3,11 +3,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams, SingleFileParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -54,7 +55,7 @@ describe('FilesStorageService get methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { 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 0aea2824d6c..1c3460c3926 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 @@ -3,11 +3,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { getPaths, unmarkForDelete } from '../helper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -44,7 +45,7 @@ describe('FilesStorageService restore methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { @@ -67,7 +68,7 @@ describe('FilesStorageService restore methods', () => { }).compile(); service = module.get(FilesStorageService); - storageClient = module.get(S3ClientAdapter); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); fileRecordRepo = module.get(FileRecordRepo); }); 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 195904841b9..9da800baf90 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 @@ -4,13 +4,14 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import _ from 'lodash'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams, RenameFileParams, ScanResultParams, SingleFileParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { ErrorType } from '../error'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { FileRecordMapper, FilesStorageMapper } from '../mapper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; @@ -58,7 +59,7 @@ describe('FilesStorageService update methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { 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 d73c71e47a0..dd3f2c60def 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 @@ -4,18 +4,26 @@ import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { Readable } from 'stream'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; +import StreamMimeType from 'stream-mime-type-cjs/stream-mime-type-cjs-index'; import { FileRecordParams } from '../controller/dto'; import { FileDto } from '../dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { ErrorType } from '../error'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; import { createFileRecord, resolveFileNameDuplicates } from '../helper'; import { FileRecordRepo } from '../repo'; import { FilesStorageService } from './files-storage.service'; +jest.mock('stream-mime-type-cjs/stream-mime-type-cjs-index', () => { + return { + getMimeType: jest.fn(), + }; +}); + const buildFileRecordsWithParams = () => { const parentId = new ObjectId().toHexString(); const parentSchoolId = new ObjectId().toHexString(); @@ -50,7 +58,7 @@ describe('FilesStorageService upload methods', () => { providers: [ FilesStorageService, { - provide: S3ClientAdapter, + provide: FILES_STORAGE_S3_CONNECTION, useValue: createMock(), }, { @@ -73,7 +81,7 @@ describe('FilesStorageService upload methods', () => { }).compile(); service = module.get(FilesStorageService); - storageClient = module.get(S3ClientAdapter); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); fileRecordRepo = module.get(FileRecordRepo); antivirusService = module.get(AntivirusService); configService = module.get(ConfigService); @@ -93,31 +101,22 @@ describe('FilesStorageService upload methods', () => { }); describe('uploadFile is called', () => { - const createUploadFileParams = () => { + const createUploadFileParams = (props: { mimeType: string } = { mimeType: 'dto-mime-type' }) => { const { params, fileRecords, parentId: userId } = buildFileRecordsWithParams(); const file = createMock(); - file.data = Readable.from('abc'); + const readable = Readable.from('abc'); + file.data = readable; file.name = fileRecords[0].name; - file.mimeType = 'mimeType'; + file.mimeType = props.mimeType; const fileSize = 3; const fileRecord = createFileRecord(file.name, 0, file.mimeType, params, userId); const { securityCheck, ...expectedFileRecord } = fileRecord; expectedFileRecord.name = resolveFileNameDuplicates(fileRecord.name, fileRecords); - - const getFileRecordsOfParentSpy = jest - .spyOn(service, 'getFileRecordsOfParent') - .mockResolvedValue([[fileRecord], 1]); - - // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. - // eslint-disable-next-line @typescript-eslint/require-await - fileRecordRepo.save.mockImplementation(async (fr) => { - if (fr instanceof FileRecord && !fr._id) { - fr._id = new ObjectId(); - } - }); + const detectedMimeType = 'detected-mime-type'; + expectedFileRecord.mimeType = detectedMimeType; return { params, @@ -127,40 +126,106 @@ describe('FilesStorageService upload methods', () => { fileRecord, expectedFileRecord, fileRecords, - getFileRecordsOfParentSpy, + readable, + detectedMimeType, }; }; - it('should call getFileRecordsOfParent with correct params', async () => { - const { params, file, userId, getFileRecordsOfParentSpy } = createUploadFileParams(); + describe('WHEN file records of parent, file record repo save and get mime type are successfull', () => { + const setup = () => { + const { params, file, fileSize, userId, fileRecord, expectedFileRecord, detectedMimeType, readable } = + createUploadFileParams(); + + const getFileRecordsOfParentSpy = jest + .spyOn(service, 'getFileRecordsOfParent') + .mockResolvedValue([[fileRecord], 1]); + + const getMimeTypeSpy = jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); - await service.uploadFile(userId, params, file); + return { params, file, userId, getFileRecordsOfParentSpy, getMimeTypeSpy, fileSize, expectedFileRecord }; + }; - expect(getFileRecordsOfParentSpy).toHaveBeenCalledWith(params.parentId); - }); + it('should call getMimeType with correct params', async () => { + const { params, file, userId, getMimeTypeSpy } = setup(); + + await service.uploadFile(userId, params, file); - it('should call fileRecordRepo.save twice with correct params', async () => { - const { params, file, fileSize, userId, expectedFileRecord } = createUploadFileParams(); + expect(getMimeTypeSpy).toHaveBeenCalledWith(file.data, { strict: true }); + }); + + it('should call getFileRecordsOfParent with correct params', async () => { + const { params, file, userId, getFileRecordsOfParentSpy } = setup(); + + await service.uploadFile(userId, params, file); + + expect(getFileRecordsOfParentSpy).toHaveBeenCalledWith(params.parentId); + }); + + it('should call fileRecordRepo.save twice with correct params', async () => { + const { params, file, fileSize, userId, expectedFileRecord } = setup(); - await service.uploadFile(userId, params, file); + await service.uploadFile(userId, params, file); - expect(fileRecordRepo.save).toHaveBeenCalledTimes(2); + expect(fileRecordRepo.save).toHaveBeenCalledTimes(2); - expect(fileRecordRepo.save).toHaveBeenLastCalledWith( - expect.objectContaining({ - ...expectedFileRecord, - size: fileSize, - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - }) - ); + expect(fileRecordRepo.save).toHaveBeenLastCalledWith( + expect.objectContaining({ + ...expectedFileRecord, + size: fileSize, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + ); + }); + + it('should call antivirusService.send with fileRecord', async () => { + const { params, file, userId } = setup(); + + const fileRecord = await service.uploadFile(userId, params, file); + + expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); + }); + + it('should call storageClient.create with correct params', async () => { + const { params, file, userId } = setup(); + + const fileRecord = await service.uploadFile(userId, params, file); + + const filePath = [fileRecord.schoolId, fileRecord.id].join('/'); + expect(storageClient.create).toHaveBeenCalledWith(filePath, file); + }); + + it('should return an instance of FileRecord', async () => { + const { params, file, userId } = setup(); + + const result = await service.uploadFile(userId, params, file); + + expect(result).toBeInstanceOf(FileRecord); + }); }); describe('WHEN file record repo throws error', () => { const setup = () => { - const { params, file, userId, fileRecord, expectedFileRecord } = createUploadFileParams(); + const { params, file, userId, fileRecord, expectedFileRecord, detectedMimeType, readable } = + createUploadFileParams(); const error = new Error('test'); + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + fileRecordRepo.save.mockRejectedValueOnce(error); return { params, file, userId, fileRecord, expectedFileRecord, error }; @@ -174,20 +239,26 @@ describe('FilesStorageService upload methods', () => { }); }); - it('should call storageClient.create with correct params', async () => { - const { params, file, userId } = createUploadFileParams(); - - const fileRecord = await service.uploadFile(userId, params, file); - - const filePath = [fileRecord.schoolId, fileRecord.id].join('/'); - expect(storageClient.create).toHaveBeenCalledWith(filePath, file); - }); - describe('WHEN storageClient throws error', () => { const setup = () => { - const { params, file, userId, fileRecord, expectedFileRecord } = createUploadFileParams(); + const { params, file, userId, fileRecord, expectedFileRecord, detectedMimeType, readable } = + createUploadFileParams(); const error = new Error('test'); + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); + storageClient.create.mockRejectedValueOnce(error); return { params, file, userId, fileRecord, expectedFileRecord, error }; @@ -204,9 +275,23 @@ describe('FilesStorageService upload methods', () => { describe('WHEN file is too big', () => { const setup = () => { - const { params, file, userId } = createUploadFileParams(); + const { params, file, userId, fileRecord, detectedMimeType, readable } = createUploadFileParams(); + + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); - configService.get.mockReturnValueOnce(1); + configService.get.mockReturnValueOnce(2); const error = new BadRequestException(ErrorType.FILE_TOO_BIG); return { params, file, userId, error }; @@ -221,36 +306,76 @@ describe('FilesStorageService upload methods', () => { }); }); - it('should correctly set file size', async () => { - const { params, file, fileSize, userId } = createUploadFileParams(); + describe('WHEN file size is bigger than maxSecurityCheckFileSize', () => { + const setup = () => { + const { params, file, userId, expectedFileRecord, detectedMimeType, readable, fileRecord } = + createUploadFileParams(); - await service.uploadFile(userId, params, file); + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); - expect(fileRecordRepo.save).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - size: fileSize, - }) - ); - }); + // Mock for max file size + configService.get.mockReturnValueOnce(10); + + // Mock for max security check file size + configService.get.mockReturnValueOnce(2); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); + + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + + return { params, file, userId, expectedFileRecord }; + }; + + it('should call save with WONT_CHECK status', async () => { + const { params, file, userId } = setup(); + + await service.uploadFile(userId, params, file); - it('should call antivirusService.send with fileRecord', async () => { - const { params, file, userId } = createUploadFileParams(); + expect(fileRecordRepo.save).toHaveBeenNthCalledWith( + 1, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ securityCheck: expect.objectContaining({ status: 'wont_check' }) }) + ); + }); + + it('should not call antivirus send', async () => { + const { params, file, userId } = setup(); - const fileRecord = await service.uploadFile(userId, params, file); + await service.uploadFile(userId, params, file); - expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); + expect(antivirusService.send).not.toHaveBeenCalled(); + }); }); describe('WHEN antivirusService throws error', () => { const setup = () => { - const { params, file, userId } = createUploadFileParams(); + const { params, file, userId, fileRecord, detectedMimeType, readable } = createUploadFileParams(); const error = new Error('test'); - antivirusService.send.mockImplementationOnce(() => { - throw error; + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: detectedMimeType, stream: readable as unknown as undefined }); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } }); + antivirusService.send.mockRejectedValueOnce(error); + return { params, file, userId, error }; }; @@ -263,12 +388,71 @@ describe('FilesStorageService upload methods', () => { }); }); - it('should return an instance of FileRecord', async () => { - const { params, file, userId } = createUploadFileParams(); + describe('WHEN getMimeType returns undefined', () => { + const setup = () => { + const { params, file, userId, fileRecord, readable } = createUploadFileParams(); + + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); - const result = await service.uploadFile(userId, params, file); + jest + .spyOn(StreamMimeType, 'getMimeType') + .mockResolvedValueOnce({ mime: undefined as unknown as string, stream: readable as unknown as undefined }); - expect(result).toBeInstanceOf(FileRecord); + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); + + return { params, file, userId }; + }; + + it('should use dto mime type', async () => { + const { params, file, userId } = setup(); + + const fileRecord = await service.uploadFile(userId, params, file); + + expect(fileRecord.mimeType).toEqual(file.mimeType); + }); + }); + + describe('WHEN mime type cant be detected from stream', () => { + const setup = () => { + const mimeType = 'image/svg+xml'; + const { params, file, userId, fileRecord } = createUploadFileParams({ mimeType }); + + jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + + const getMimeTypeSpy = jest.spyOn(StreamMimeType, 'getMimeType'); + + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. + // eslint-disable-next-line @typescript-eslint/require-await + fileRecordRepo.save.mockImplementation(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + }); + + return { params, file, userId, getMimeTypeSpy, mimeType }; + }; + + it('should use dto mime type', async () => { + const { params, file, userId, mimeType } = setup(); + + await service.uploadFile(userId, params, file); + + expect(fileRecordRepo.save).toHaveBeenCalledWith(expect.objectContaining({ mimeType })); + }); + + it('should not detect from stream', async () => { + const { params, file, userId, getMimeTypeSpy } = setup(); + + await service.uploadFile(userId, params, file); + + expect(getMimeTypeSpy).not.toHaveBeenCalled(); + }); }); }); }); 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 05ccdb2b80c..acdab712f78 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 @@ -1,16 +1,18 @@ import { BadRequestException, ConflictException, + Inject, Injectable, - InternalServerErrorException, NotAcceptableException, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Counted, EntityId } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; +import { Readable } from 'stream'; +import StreamMimeType from 'stream-mime-type-cjs/stream-mime-type-cjs-index'; import { CopyFileResponse, CopyFilesOfParentParams, @@ -21,27 +23,27 @@ import { SingleFileParams, } from '../controller/dto'; import { FileDto } from '../dto'; -import { FileRecord } from '../entity'; +import { FileRecord, ScanStatus } from '../entity'; import { ErrorType } from '../error'; -import { IFileStorageConfig } from '../files-storage.config'; +import { FILES_STORAGE_S3_CONNECTION, IFileStorageConfig } from '../files-storage.config'; import { + createCopyFiles, createFileRecord, - createICopyFiles, createPath, getPaths, markForDelete, resolveFileNameDuplicates, unmarkForDelete, } from '../helper'; -import { IGetFileResponse } from '../interface'; -import { CopyFileResponseBuilder, FileRecordMapper, FilesStorageMapper } from '../mapper'; +import { GetFileResponse } from '../interface'; +import { CopyFileResponseBuilder, FileRecordMapper, FileResponseBuilder, FilesStorageMapper } from '../mapper'; import { FileRecordRepo } from '../repo'; @Injectable() export class FilesStorageService { constructor( private readonly fileRecordRepo: FileRecordRepo, - private readonly storageClient: S3ClientAdapter, + @Inject(FILES_STORAGE_S3_CONNECTION) private readonly storageClient: S3ClientAdapter, private readonly antivirusService: AntivirusService, private readonly configService: ConfigService, private logger: LegacyLogger @@ -76,7 +78,9 @@ export class FilesStorageService { // upload public async uploadFile(userId: EntityId, params: FileRecordParams, file: FileDto): Promise { - const fileRecord = await this.createFileRecord(file, params, userId); + const { fileRecord, stream } = await this.createFileRecord(file, params, userId); + // MimeType Detection consumes part of the stream, so the restored stream is passed on + file.data = stream; await this.fileRecordRepo.save(fileRecord); await this.createFileInStorageAndRollbackOnError(fileRecord, params, file); @@ -84,13 +88,53 @@ export class FilesStorageService { return fileRecord; } - private async createFileRecord(file: FileDto, params: FileRecordParams, userId: EntityId): Promise { + private async createFileRecord( + file: FileDto, + params: FileRecordParams, + userId: EntityId + ): Promise<{ fileRecord: FileRecord; stream: Readable }> { const fileName = await this.resolveFileName(file, params); + const { mimeType, stream } = await this.detectMimeType(file); // Create fileRecord with 0 as initial file size, because it is overwritten later anyway. - const fileRecord = createFileRecord(fileName, 0, file.mimeType, params, userId); + const fileRecord = createFileRecord(fileName, 0, mimeType, params, userId); - return fileRecord; + return { fileRecord, stream }; + } + + private async detectMimeType(file: FileDto): Promise<{ mimeType: string; stream: Readable }> { + if (this.isStreamMimeTypeDetectionPossible(file.mimeType)) { + const { stream, mime: detectedMimeType } = await this.detectMimeTypeByStream(file.data); + + const mimeType = detectedMimeType ?? file.mimeType; + + return { mimeType, stream }; + } + + return { mimeType: file.mimeType, stream: file.data }; + } + + private isStreamMimeTypeDetectionPossible(mimeType: string) { + const mimTypes = [ + 'text/csv', + 'image/svg+xml', + 'application/msword', + 'application/vnd.ms-powerpoint', + 'application/vnd.ms-excel', + ]; + + const result = !mimTypes.includes(mimeType); + + return result; + } + + private async detectMimeTypeByStream(file: Readable): Promise<{ mime?: string; stream: Readable }> { + const { stream, mime } = await StreamMimeType.getMimeType(file, { + strict: true, + }); + const readable = new Readable().wrap(stream); + + return { mime, stream: readable }; } private async resolveFileName(file: FileDto, params: FileRecordParams): Promise { @@ -121,7 +165,7 @@ export class FilesStorageService { this.throwErrorIfFileIsTooBig(fileRecord.size); await this.fileRecordRepo.save(fileRecord); - this.antivirusService.send(fileRecord.getSecurityToken()); + await this.sendToAntivirus(fileRecord); } catch (error) { await this.storageClient.delete([filePath]); await this.fileRecordRepo.delete(fileRecord); @@ -143,6 +187,17 @@ export class FilesStorageService { return promise; } + private async sendToAntivirus(fileRecord: FileRecord): Promise { + const maxSecurityCheckFileSize = this.configService.get('MAX_SECURITY_CHECK_FILE_SIZE'); + + if (fileRecord.size > maxSecurityCheckFileSize) { + fileRecord.updateSecurityCheckStatus(ScanStatus.WONT_CHECK, 'File is too big'); + await this.fileRecordRepo.save(fileRecord); + } else { + await this.antivirusService.send(fileRecord.getSecurityToken()); + } + } + private throwErrorIfFileIsTooBig(fileSize: number): void { if (fileSize > this.configService.get('MAX_FILE_SIZE')) { throw new BadRequestException(ErrorType.FILE_TOO_BIG); @@ -191,13 +246,10 @@ export class FilesStorageService { } } - public async downloadFile( - schoolId: EntityId, - fileRecordId: EntityId, - bytesRange?: string - ): Promise { - const pathToFile = createPath(schoolId, fileRecordId); - const response = await this.storageClient.get(pathToFile, bytesRange); + public async downloadFile(fileRecord: FileRecord, bytesRange?: string): Promise { + const pathToFile = createPath(fileRecord.schoolId, fileRecord.id); + const file = await this.storageClient.get(pathToFile, bytesRange); + const response = FileResponseBuilder.build(file, fileRecord.getName()); return response; } @@ -206,11 +258,11 @@ export class FilesStorageService { fileRecord: FileRecord, params: DownloadFileParams, bytesRange?: string - ): Promise { + ): Promise { this.checkFileName(fileRecord, params); this.checkScanStatus(fileRecord); - const response = await this.downloadFile(fileRecord.getSchoolId(), fileRecord.id, bytesRange); + const response = await this.downloadFile(fileRecord, bytesRange); return response; } @@ -227,7 +279,7 @@ export class FilesStorageService { await this.deleteFilesInFilesStorageClient(fileRecords); } catch (error) { await this.fileRecordRepo.save(fileRecords); - throw new InternalServerErrorException(error, `${FilesStorageService.name}:delete`); + throw error; } } @@ -263,7 +315,7 @@ export class FilesStorageService { } catch (err) { markForDelete(fileRecords); await this.fileRecordRepo.save(fileRecords); - throw new InternalServerErrorException(err, `${FilesStorageService.name}:restore`); + throw err; } } @@ -316,9 +368,9 @@ export class FilesStorageService { return fileRecord; } - private sendToAntiVirusService(fileRecord: FileRecord) { + private async sendToAntiVirusService(fileRecord: FileRecord) { if (fileRecord.isPending()) { - this.antivirusService.send(fileRecord.getSecurityToken()); + await this.antivirusService.send(fileRecord.getSecurityToken()); } } @@ -327,10 +379,10 @@ export class FilesStorageService { targetFile: FileRecord ): Promise { try { - const paths = createICopyFiles(sourceFile, targetFile); + const paths = createCopyFiles(sourceFile, targetFile); await this.storageClient.copy([paths]); - this.sendToAntiVirusService(targetFile); + await this.sendToAntiVirusService(targetFile); const copyFileResponse = CopyFileResponseBuilder.build(targetFile.id, sourceFile.id, targetFile.getName()); return copyFileResponse; 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 new file mode 100644 index 00000000000..ed4592b97da --- /dev/null +++ b/apps/server/src/modules/files-storage/service/preview.service.spec.ts @@ -0,0 +1,799 @@ +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 { S3ClientAdapter } from '@shared/infra/s3-client'; +import { fileRecordFactory, setupEntities } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { Readable } from 'stream'; +import { FileRecordParams } from '../controller/dto'; +import { FileRecord, FileRecordParentType, ScanStatus } from '../entity'; +import { ErrorType } from '../error'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; +import { createPreviewDirectoryPath, createPreviewFilePath, createPreviewNameHash } from '../helper'; +import { TestHelper } from '../helper/test-helper'; +import { PreviewWidth } from '../interface'; +import { PreviewOutputMimeTypes } from '../interface/preview-output-mime-types.enum'; +import { FileDtoBuilder, FileResponseBuilder } from '../mapper'; +import { FilesStorageService } from './files-storage.service'; +import { PreviewService } from './preview.service'; + +const streamMock = jest.fn(); +const resizeMock = jest.fn(); +const imageMagickMock = () => { + return { stream: streamMock, resize: resizeMock, data: Readable.from('text') }; +}; +jest.mock('gm', () => { + return { + subClass: () => imageMagickMock, + }; +}); + +const buildFileRecordWithParams = (mimeType: string, scanStatus?: ScanStatus) => { + const parentId = new ObjectId().toHexString(); + const parentSchoolId = new ObjectId().toHexString(); + const fileRecord = fileRecordFactory.buildWithId({ + parentId, + schoolId: parentSchoolId, + name: 'text.png', + mimeType, + }); + fileRecord.securityCheck.status = scanStatus ?? ScanStatus.VERIFIED; + + const params: FileRecordParams = { + schoolId: parentSchoolId, + parentId, + parentType: FileRecordParentType.User, + }; + + return { params, fileRecord, parentId }; +}; + +const defaultPreviewParams = { + outputFormat: PreviewOutputMimeTypes.IMAGE_WEBP, + forceUpdate: false, +}; + +const defaultPreviewParamsWithWidth = { + ...defaultPreviewParams, + width: PreviewWidth.WIDTH_500, +}; + +describe('PreviewService', () => { + let module: TestingModule; + let previewService: PreviewService; + let fileStorageService: DeepMocked; + let s3ClientAdapter: DeepMocked; + + beforeAll(async () => { + await setupEntities([FileRecord]); + + module = await Test.createTestingModule({ + providers: [ + PreviewService, + { + provide: FilesStorageService, + useValue: createMock(), + }, + { + provide: FILES_STORAGE_S3_CONNECTION, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + previewService = module.get(PreviewService); + fileStorageService = module.get(FilesStorageService); + s3ClientAdapter = module.get(FILES_STORAGE_S3_CONNECTION); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getPreview is called', () => { + describe('WHEN preview is possbile', () => { + describe('WHEN forceUpdate is true', () => { + describe('WHEN width and outputformat are not set', () => { + describe('WHEN download of original and preview file is successfull', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const orignalMimeType = 'image/png'; + const format = orignalMimeType.split('/')[1]; + const { fileRecord } = buildFileRecordWithParams(orignalMimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const previewFile = TestHelper.createFile(); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + const fileNameWithoutExtension = fileRecord.name.split('.')[0]; + const name = `${fileNameWithoutExtension}.${format}`; + const previewFileResponse = FileResponseBuilder.build(previewFile, name); + + const hash = createPreviewNameHash(fileRecord.id, {}); + const previewFileDto = FileDtoBuilder.build(hash, previewFile.data, orignalMimeType); + const previewPath = createPreviewFilePath(fileRecord.getSchoolId(), hash, fileRecord.id); + streamMock.mockClear(); + streamMock.mockReturnValueOnce(previewFileDto.data); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + previewFileDto, + previewPath, + previewFileResponse, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange); + + expect(fileStorageService.download).toHaveBeenCalledWith(fileRecord, downloadParams, bytesRange); + }); + + it('calls image magicks stream method', async () => { + const { fileRecord, downloadParams, previewParams, format } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(streamMock).toHaveBeenCalledWith(format); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters create method', async () => { + const { fileRecord, downloadParams, previewParams, previewFileDto, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.create).toHaveBeenCalledWith(previewPath, previewFileDto); + expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters get method', async () => { + const { fileRecord, downloadParams, previewParams, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.get).toHaveBeenCalledWith(previewPath, undefined); + expect(s3ClientAdapter.get).toHaveBeenCalledTimes(1); + }); + + it('returns preview file response', async () => { + const { fileRecord, downloadParams, previewParams, previewFileResponse } = setup(); + + const response = await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(response).toEqual(previewFileResponse); + }); + }); + + describe('WHEN download of original file throws error', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { forceUpdate: true }; + + const error = new Error('testError'); + fileStorageService.download.mockRejectedValueOnce(error); + + return { fileRecord, downloadParams, previewParams, error }; + }; + + it('passes error', async () => { + const { fileRecord, downloadParams, previewParams, error } = setup(); + + await expect(previewService.getPreview(fileRecord, downloadParams, previewParams)).rejects.toThrowError( + error + ); + }); + }); + + describe('WHEN create of preview file throws error', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new Error('testError'); + s3ClientAdapter.create.mockRejectedValueOnce(error); + + return { fileRecord, downloadParams, previewParams, error }; + }; + + it('passes error', async () => { + const { fileRecord, downloadParams, previewParams, error } = setup(); + + await expect(previewService.getPreview(fileRecord, downloadParams, previewParams)).rejects.toThrowError( + error + ); + }); + }); + + describe('WHEN get of preview file throws error', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new Error('testError'); + s3ClientAdapter.get.mockRejectedValueOnce(error); + + return { fileRecord, downloadParams, previewParams, error }; + }; + + it('passes error', async () => { + const { fileRecord, downloadParams, previewParams, error } = setup(); + + await expect(previewService.getPreview(fileRecord, downloadParams, previewParams)).rejects.toThrowError( + error + ); + }); + }); + }); + + describe('WHEN width and outputFormat are set', () => { + describe('WHEN download of original and preview file is successfull', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { + ...defaultPreviewParamsWithWidth, + forceUpdate: true, + }; + const format = previewParams.outputFormat.split('/')[1]; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const previewFile = TestHelper.createFile(); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + const fileNameWithoutExtension = fileRecord.name.split('.')[0]; + const name = `${fileNameWithoutExtension}.${format}`; + const previewFileResponse = FileResponseBuilder.build(previewFile, name); + + const hash = createPreviewNameHash(fileRecord.id, previewParams); + const previewFileDto = FileDtoBuilder.build(hash, previewFile.data, previewParams.outputFormat); + const previewPath = createPreviewFilePath(fileRecord.getSchoolId(), hash, fileRecord.id); + + streamMock.mockClear(); + streamMock.mockReturnValueOnce(previewFileDto.data); + + resizeMock.mockClear(); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + previewFileDto, + previewPath, + previewFileResponse, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange); + + expect(fileStorageService.download).toHaveBeenCalledWith(fileRecord, downloadParams, bytesRange); + }); + + it('calls image magicks resize method', async () => { + const { fileRecord, downloadParams, previewParams } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(resizeMock).toHaveBeenCalledWith(previewParams.width, undefined, '>'); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + + it('calls image magicks stream method', async () => { + const { fileRecord, downloadParams, previewParams, format } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(streamMock).toHaveBeenCalledWith(format); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters create method', async () => { + const { fileRecord, downloadParams, previewParams, previewFileDto, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.create).toHaveBeenCalledWith(previewPath, previewFileDto); + expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters get method', async () => { + const { fileRecord, downloadParams, previewParams, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.get).toHaveBeenCalledWith(previewPath, undefined); + expect(s3ClientAdapter.get).toHaveBeenCalledTimes(1); + }); + + it('returns preview file response', async () => { + const { fileRecord, downloadParams, previewParams, previewFileResponse } = setup(); + + const response = await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(response).toEqual(previewFileResponse); + }); + }); + + describe('WHEN download of original file throws error', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { ...defaultPreviewParams, forceUpdate: true }; + + const error = new Error('testError'); + fileStorageService.download.mockRejectedValueOnce(error); + + return { fileRecord, downloadParams, previewParams, error }; + }; + + it('passes error', async () => { + const { fileRecord, downloadParams, previewParams, error } = setup(); + + await expect(previewService.getPreview(fileRecord, downloadParams, previewParams)).rejects.toThrowError( + error + ); + }); + }); + }); + }); + + describe('WHEN forceUpdate is false', () => { + describe('WHEN width and outputFormat are set', () => { + describe('WHEN S3ClientAdapter get returns already stored preview file', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { + ...defaultPreviewParamsWithWidth, + }; + const format = previewParams.outputFormat.split('/')[1]; + + const previewFile = TestHelper.createFile(); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + const fileNameWithoutExtension = fileRecord.name.split('.')[0]; + const name = `${fileNameWithoutExtension}.${format}`; + const previewFileResponse = FileResponseBuilder.build(previewFile, name); + + const hash = createPreviewNameHash(fileRecord.id, previewParams); + const previewPath = createPreviewFilePath(fileRecord.getSchoolId(), hash, fileRecord.id); + + resizeMock.mockClear(); + streamMock.mockClear(); + + return { + fileRecord, + downloadParams, + previewParams, + previewPath, + previewFileResponse, + }; + }; + + it('calls S3ClientAdapters get method', async () => { + const { fileRecord, downloadParams, previewParams, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.get).toHaveBeenCalledWith(previewPath, undefined); + expect(s3ClientAdapter.get).toHaveBeenCalledTimes(1); + }); + + it('returns preview file response', async () => { + const { fileRecord, downloadParams, previewParams, previewFileResponse } = setup(); + + const response = await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(response).toEqual(previewFileResponse); + }); + + it('does not call image magicks resize and stream method', async () => { + const { fileRecord, downloadParams, previewParams } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(resizeMock).not.toHaveBeenCalled(); + expect(streamMock).not.toHaveBeenCalled(); + }); + }); + + describe('WHEN S3ClientAdapter get throws NotFoundException', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { + ...defaultPreviewParamsWithWidth, + }; + const format = previewParams.outputFormat.split('/')[1]; + + const error = new NotFoundException(); + s3ClientAdapter.get.mockRejectedValueOnce(error); + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const previewFile = TestHelper.createFile(); + s3ClientAdapter.get.mockResolvedValueOnce(previewFile); + + const fileNameWithoutExtension = fileRecord.name.split('.')[0]; + const name = `${fileNameWithoutExtension}.${format}`; + const previewFileResponse = FileResponseBuilder.build(previewFile, name); + + const hash = createPreviewNameHash(fileRecord.id, previewParams); + const previewFileDto = FileDtoBuilder.build(hash, previewFile.data, previewParams.outputFormat); + const previewPath = createPreviewFilePath(fileRecord.getSchoolId(), hash, fileRecord.id); + + streamMock.mockClear(); + streamMock.mockReturnValueOnce(previewFileDto.data); + + resizeMock.mockClear(); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + previewFileDto, + previewPath, + previewFileResponse, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange); + + expect(fileStorageService.download).toHaveBeenCalledWith(fileRecord, downloadParams, bytesRange); + }); + + it('calls image magicks resize method', async () => { + const { fileRecord, downloadParams, previewParams } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(resizeMock).toHaveBeenCalledWith(previewParams.width, undefined, '>'); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + + it('calls image magicks stream method', async () => { + const { fileRecord, downloadParams, previewParams, format } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(streamMock).toHaveBeenCalledWith(format); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters create method', async () => { + const { fileRecord, downloadParams, previewParams, previewFileDto, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.create).toHaveBeenCalledWith(previewPath, previewFileDto); + expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); + }); + + it('calls S3ClientAdapters get method', async () => { + const { fileRecord, downloadParams, previewParams, previewPath } = setup(); + + await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(s3ClientAdapter.get).toHaveBeenCalledWith(previewPath, undefined); + expect(s3ClientAdapter.get).toHaveBeenCalledTimes(2); + }); + + it('returns preview file response', async () => { + const { fileRecord, downloadParams, previewParams, previewFileResponse } = setup(); + + const response = await previewService.getPreview(fileRecord, downloadParams, previewParams); + + expect(response).toEqual(previewFileResponse); + }); + }); + + describe('WHEN S3ClientAdapter get throws other than NotFoundException', () => { + const setup = () => { + const mimeType = 'image/png'; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { + ...defaultPreviewParamsWithWidth, + }; + const format = previewParams.outputFormat.split('/')[1]; + + const error = new Error('testError'); + s3ClientAdapter.get.mockRejectedValueOnce(error); + + return { + fileRecord, + downloadParams, + previewParams, + format, + error, + }; + }; + + it('passes error', async () => { + const { fileRecord, downloadParams, previewParams, error } = setup(); + + await expect(previewService.getPreview(fileRecord, downloadParams, previewParams)).rejects.toThrow(error); + }); + }); + }); + }); + }); + + describe('WHEN preview is not possible', () => { + describe('WHEN MIME Type is not supported', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'application/zip'; + const format = mimeType.split('/')[1]; + const { fileRecord } = buildFileRecordWithParams(mimeType); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { ...defaultPreviewParams, forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new UnprocessableEntityException(ErrorType.PREVIEW_NOT_POSSIBLE); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + error, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange, error } = setup(); + + await expect( + previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange) + ).rejects.toThrowError(error); + }); + }); + + describe('WHEN scan status is pending', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'image/png'; + const format = mimeType.split('/')[1]; + const { fileRecord } = buildFileRecordWithParams(mimeType, ScanStatus.PENDING); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { ...defaultPreviewParams, forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new UnprocessableEntityException(ErrorType.PREVIEW_NOT_POSSIBLE); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + error, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange, error } = setup(); + + await expect( + previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange) + ).rejects.toThrowError(error); + }); + }); + + describe('WHEN scan status is error', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'image/png'; + const format = mimeType.split('/')[1]; + const { fileRecord } = buildFileRecordWithParams(mimeType, ScanStatus.ERROR); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { ...defaultPreviewParams, forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new UnprocessableEntityException(ErrorType.PREVIEW_NOT_POSSIBLE); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + error, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange, error } = setup(); + + await expect( + previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange) + ).rejects.toThrowError(error); + }); + }); + + describe('WHEN scan status is blocked', () => { + const setup = () => { + const bytesRange = 'bytes=0-100'; + const mimeType = 'image/png'; + const format = mimeType.split('/')[1]; + const { fileRecord } = buildFileRecordWithParams(mimeType, ScanStatus.BLOCKED); + const downloadParams = { + fileRecordId: fileRecord.id, + fileName: fileRecord.name, + }; + const previewParams = { ...defaultPreviewParams, forceUpdate: true }; + + const originalFileResponse = TestHelper.createFileResponse(); + fileStorageService.download.mockResolvedValueOnce(originalFileResponse); + + const error = new UnprocessableEntityException(ErrorType.PREVIEW_NOT_POSSIBLE); + + return { + bytesRange, + fileRecord, + downloadParams, + previewParams, + format, + error, + }; + }; + + it('calls download with correct params', async () => { + const { fileRecord, downloadParams, previewParams, bytesRange, error } = setup(); + + await expect( + previewService.getPreview(fileRecord, downloadParams, previewParams, bytesRange) + ).rejects.toThrowError(error); + }); + }); + }); + }); + + describe('deletePreviews', () => { + describe('WHEN deleteDirectory deletes successfully', () => { + const setup = () => { + const { fileRecord } = buildFileRecordWithParams('image/png'); + const previewParams = { + ...defaultPreviewParams, + }; + const format = previewParams.outputFormat.split('/')[1]; + const directoryPath = createPreviewDirectoryPath(fileRecord.schoolId, fileRecord.id); + + return { + fileRecord, + previewParams, + format, + directoryPath, + }; + }; + + it('calls deleteDirectory with correct params', async () => { + const { fileRecord, directoryPath } = setup(); + + await previewService.deletePreviews([fileRecord]); + + expect(s3ClientAdapter.deleteDirectory).toHaveBeenCalledWith(directoryPath); + }); + }); + + describe('WHEN deleteDirectory throws error', () => { + const setup = () => { + const { fileRecord } = buildFileRecordWithParams('image/png'); + const previewParams = { + ...defaultPreviewParams, + }; + const format = previewParams.outputFormat.split('/')[1]; + + const error = new Error('testError'); + s3ClientAdapter.deleteDirectory.mockRejectedValueOnce(error); + + return { + fileRecord, + previewParams, + format, + error, + }; + }; + + it('does not pass error', async () => { + const { fileRecord } = setup(); + + await previewService.deletePreviews([fileRecord]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files-storage/service/preview.service.ts b/apps/server/src/modules/files-storage/service/preview.service.ts new file mode 100644 index 00000000000..7cf33afe5fe --- /dev/null +++ b/apps/server/src/modules/files-storage/service/preview.service.ts @@ -0,0 +1,150 @@ +import { Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { LegacyLogger } from '@src/core/logger'; +import { subClass } from 'gm'; +import { PassThrough } from 'stream'; +import { DownloadFileParams, PreviewParams } from '../controller/dto'; +import { FileRecord, PreviewStatus } from '../entity'; +import { ErrorType } from '../error'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; +import { createPreviewDirectoryPath, createPreviewFilePath, createPreviewNameHash } from '../helper'; +import { GetFileResponse, PreviewFileParams } from '../interface'; +import { PreviewOutputMimeTypes } from '../interface/preview-output-mime-types.enum'; +import { FileDtoBuilder, FileResponseBuilder } from '../mapper'; +import { FilesStorageService } from './files-storage.service'; + +@Injectable() +export class PreviewService { + constructor( + @Inject(FILES_STORAGE_S3_CONNECTION) private readonly storageClient: S3ClientAdapter, + private readonly fileStorageService: FilesStorageService, + private logger: LegacyLogger + ) { + this.logger.setContext(PreviewService.name); + } + + public async getPreview( + fileRecord: FileRecord, + downloadParams: DownloadFileParams, + previewParams: PreviewParams, + bytesRange?: string + ): Promise { + this.checkIfPreviewPossible(fileRecord); + + const hash = createPreviewNameHash(fileRecord.id, previewParams); + const filePath = createPreviewFilePath(fileRecord.getSchoolId(), hash, fileRecord.id); + + let response: GetFileResponse; + + const previewFileParams = { fileRecord, downloadParams, previewParams, hash, filePath, bytesRange }; + + if (previewParams.forceUpdate) { + response = await this.generatePreview(previewFileParams); + } else { + response = await this.tryGetPreviewOrGenerate(previewFileParams); + } + + return response; + } + + public async deletePreviews(fileRecords: FileRecord[]): Promise { + try { + const paths = fileRecords.map((fileRecord) => + createPreviewDirectoryPath(fileRecord.getSchoolId(), fileRecord.id) + ); + + const promises = paths.map((path) => this.storageClient.deleteDirectory(path)); + + await Promise.all(promises); + } catch (error) { + this.logger.warn(error); + } + } + + private checkIfPreviewPossible(fileRecord: FileRecord): void | UnprocessableEntityException { + if (fileRecord.getPreviewStatus() !== PreviewStatus.PREVIEW_POSSIBLE) { + this.logger.warn(`could not generate preview for : ${fileRecord.id} ${fileRecord.mimeType}`); + throw new UnprocessableEntityException(ErrorType.PREVIEW_NOT_POSSIBLE); + } + } + + private async tryGetPreviewOrGenerate(params: PreviewFileParams): Promise { + let file: GetFileResponse; + + try { + file = await this.getPreviewFile(params); + } catch (error) { + if (!(error instanceof NotFoundException)) { + throw error; + } + + file = await this.generatePreview(params); + } + + return file; + } + + private async getPreviewFile(params: PreviewFileParams): Promise { + const { fileRecord, filePath, bytesRange, previewParams } = params; + const name = this.getPreviewName(fileRecord, previewParams.outputFormat); + const file = await this.storageClient.get(filePath, bytesRange); + + const response = FileResponseBuilder.build(file, name); + + return response; + } + + private async generatePreview(params: PreviewFileParams): Promise { + const { fileRecord, downloadParams, previewParams, hash, filePath, bytesRange } = params; + + const original = await this.fileStorageService.download(fileRecord, downloadParams, bytesRange); + const preview = this.resizeAndConvert(original, fileRecord, previewParams); + + const format = previewParams.outputFormat ?? fileRecord.mimeType; + const fileDto = FileDtoBuilder.build(hash, preview, format); + await this.storageClient.create(filePath, fileDto); + + const response = await this.getPreviewFile(params); + + return response; + } + + private resizeAndConvert( + original: GetFileResponse, + fileRecord: FileRecord, + previewParams: PreviewParams + ): PassThrough { + const mimeType = previewParams.outputFormat ?? fileRecord.mimeType; + const format = this.getFormat(mimeType); + const im = subClass({ imageMagick: true }); + + const preview = im(original.data, fileRecord.name); + const { width } = previewParams; + + if (width) { + preview.resize(width, undefined, '>'); + } + + const result = preview.stream(format); + + return result; + } + + private getFormat(mimeType: string): string { + const format = mimeType.split('/')[1]; + + return format; + } + + private getPreviewName(fileRecord: FileRecord, outputFormat?: PreviewOutputMimeTypes): string { + if (!outputFormat) { + return fileRecord.name; + } + + const fileNameWithoutExtension = fileRecord.name.split('.')[0]; + const format = this.getFormat(outputFormat); + const name = `${fileNameWithoutExtension}.${format}`; + + return name; + } +} 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 abc3fa0c323..21592585ce4 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 @@ -5,14 +5,15 @@ import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { Action, AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { CopyFileResponseBuilder } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordsWithParams = () => { @@ -103,6 +104,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); 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 cb06751310c..9a870a7f4e1 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,15 +5,16 @@ import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Counted, EntityId } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordsWithParams = () => { @@ -55,6 +56,7 @@ describe('FilesStorageUC delete methods', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; + let previewService: DeepMocked; let authorizationService: DeepMocked; beforeAll(async () => { @@ -87,12 +89,17 @@ describe('FilesStorageUC delete methods', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); filesStorageUC = module.get(FilesStorageUC); authorizationService = module.get(AuthorizationService); filesStorageService = module.get(FilesStorageService); + previewService = module.get(PreviewService); }); beforeEach(() => { @@ -118,7 +125,7 @@ describe('FilesStorageUC delete methods', () => { authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.deleteFilesOfParent.mockResolvedValueOnce(mockedResult); - return { params, userId, mockedResult, requestParams }; + return { params, userId, mockedResult, requestParams, fileRecord }; }; it('should call authorizationService.checkPermissionByReferences', async () => { @@ -143,6 +150,14 @@ describe('FilesStorageUC delete methods', () => { expect(filesStorageService.deleteFilesOfParent).toHaveBeenCalledWith(requestParams.parentId); }); + it('should call deletePreviews', async () => { + const { requestParams, userId, fileRecord } = setup(); + + await filesStorageUC.deleteFilesOfParent(userId, requestParams); + + expect(previewService.deletePreviews).toHaveBeenCalledWith([fileRecord]); + }); + it('should return results of service', async () => { const { params, userId, mockedResult } = setup(); @@ -244,6 +259,14 @@ describe('FilesStorageUC delete methods', () => { expect(filesStorageService.delete).toHaveBeenCalledWith([fileRecord]); }); + it('should call deletePreviews', async () => { + const { userId, requestParams, fileRecord } = setup(); + + await filesStorageUC.deleteOneFile(userId, requestParams); + + expect(previewService.deletePreviews).toHaveBeenCalledWith([fileRecord]); + }); + it('should return fileRecord', async () => { const { userId, requestParams, fileRecord } = setup(); 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 new file mode 100644 index 00000000000..2d62feaa08e --- /dev/null +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -0,0 +1,226 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +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/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { fileRecordFactory, setupEntities } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { AuthorizationService } from '@src/modules/authorization'; +import { SingleFileParams } from '../controller/dto'; +import { FileRecord } from '../entity'; +import { FileStorageAuthorizationContext } from '../files-storage.const'; +import { TestHelper } from '../helper/test-helper'; +import { PreviewOutputMimeTypes } from '../interface'; +import { FilesStorageMapper } from '../mapper'; +import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; +import { FilesStorageUC } from './files-storage.uc'; + +const buildFileRecordWithParams = () => { + const userId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + + const fileRecord = fileRecordFactory.buildWithId({ parentId: userId, schoolId, name: 'text.txt' }); + + const params: SingleFileParams = { + fileRecordId: fileRecord.id, + }; + + return { params, fileRecord, userId }; +}; + +const getPreviewParams = () => { + return { + outputFormat: PreviewOutputMimeTypes.IMAGE_WEBP, + forceUpdate: true, + }; +}; + +describe('FilesStorageUC', () => { + let module: TestingModule; + let filesStorageUC: FilesStorageUC; + let filesStorageService: DeepMocked; + let previewService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + await setupEntities([FileRecord]); + + module = await Test.createTestingModule({ + providers: [ + FilesStorageUC, + { + provide: S3ClientAdapter, + useValue: createMock(), + }, + { + provide: PreviewService, + useValue: createMock(), + }, + { + provide: FilesStorageService, + useValue: createMock(), + }, + { + provide: AntivirusService, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + filesStorageUC = module.get(FilesStorageUC); + authorizationService = module.get(AuthorizationService); + filesStorageService = module.get(FilesStorageService); + previewService = module.get(PreviewService); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(filesStorageUC).toBeDefined(); + }); + + describe('downloadPreview is called', () => { + describe('WHEN preview is returned and user is authorized', () => { + const setup = () => { + const { fileRecord, params, userId } = buildFileRecordWithParams(); + const fileDownloadParams = { ...params, fileName: fileRecord.name }; + const singleFileParams = FilesStorageMapper.mapToSingleFileParams(fileDownloadParams); + + const previewParams = getPreviewParams(); + const previewFileResponse = TestHelper.createFileResponse(); + + filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); + previewService.getPreview.mockResolvedValueOnce(previewFileResponse); + + return { fileDownloadParams, previewParams, userId, fileRecord, singleFileParams, previewFileResponse }; + }; + + it('should call getFileRecord with correct params', async () => { + const { fileDownloadParams, userId, previewParams, singleFileParams } = setup(); + + await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); + + expect(filesStorageService.getFileRecord).toHaveBeenCalledWith(singleFileParams); + }); + + it('should call getPreview with correct params', async () => { + const { fileDownloadParams, userId, previewParams, fileRecord } = setup(); + + await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); + + expect(previewService.getPreview).toHaveBeenCalledWith( + fileRecord, + fileDownloadParams, + previewParams, + undefined + ); + }); + + it('should call checkPermission with correct params', async () => { + const { fileDownloadParams, previewParams, userId, fileRecord } = setup(); + + await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); + + const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); + expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + userId, + allowedType, + fileRecord.parentId, + FileStorageAuthorizationContext.read + ); + }); + + it('should return correct result', async () => { + const { fileDownloadParams, previewParams, userId, previewFileResponse } = setup(); + + const result = await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); + + expect(result).toEqual(previewFileResponse); + }); + }); + + describe('WHEN getFileRecord throws error', () => { + const setup = () => { + const { fileRecord, params, userId } = buildFileRecordWithParams(); + const fileDownloadParams = { ...params, fileName: fileRecord.name }; + + const previewParams = getPreviewParams(); + + const error = new Error('test'); + filesStorageService.getFileRecord.mockRejectedValueOnce(error); + + return { fileDownloadParams, previewParams, userId, error }; + }; + + it('should pass error', async () => { + const { fileDownloadParams, userId, error, previewParams } = setup(); + + await expect(filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams)).rejects.toThrow(error); + }); + }); + + describe('WHEN user is not authorized', () => { + const setup = () => { + const { fileRecord, params, userId } = buildFileRecordWithParams(); + const fileDownloadParams = { ...params, fileName: fileRecord.name }; + + const previewParams = getPreviewParams(); + + filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); + + const error = new ForbiddenException(); + authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error); + + return { fileDownloadParams, userId, fileRecord, previewParams, error }; + }; + + it('should throw Error', async () => { + const { fileDownloadParams, userId, previewParams, error } = setup(); + + await expect(filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams)).rejects.toThrow(error); + }); + }); + + describe('WHEN getPreview throws error', () => { + const setup = () => { + const { fileRecord, params, userId } = buildFileRecordWithParams(); + const fileDownloadParams = { ...params, fileName: fileRecord.name }; + + const previewParams = getPreviewParams(); + + filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); + const error = new Error('test'); + previewService.getPreview.mockRejectedValueOnce(error); + + return { fileDownloadParams, previewParams, userId, error }; + }; + + it('should pass error', async () => { + const { fileDownloadParams, previewParams, userId, error } = setup(); + + await expect(filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams)).rejects.toThrow(error); + }); + }); + }); +}); 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 8a9bec9e3a1..ee3eb1ecef3 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 @@ -4,16 +4,17 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; -import { IGetFileResponse } from '../interface/storage-client'; +import { GetFileResponse } from '../interface'; import { FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordWithParams = () => { @@ -65,6 +66,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); @@ -91,7 +96,7 @@ describe('FilesStorageUC', () => { const { fileRecord, params, userId } = buildFileRecordWithParams(); const fileDownloadParams = { ...params, fileName: fileRecord.name }; - const fileResponse = createMock(); + const fileResponse = createMock(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); authorizationService.checkPermissionByReferences.mockResolvedValue(); @@ -204,7 +209,7 @@ describe('FilesStorageUC', () => { const setup = () => { const { fileRecord } = buildFileRecordWithParams(); const token = 'token'; - const fileResponse = createMock(); + const fileResponse = createMock(); filesStorageService.getFileRecordBySecurityCheckRequestToken.mockResolvedValueOnce(fileRecord); filesStorageService.downloadFile.mockResolvedValueOnce(fileResponse); @@ -225,7 +230,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.downloadBySecurityToken(token); - expect(filesStorageService.downloadFile).toHaveBeenCalledWith(fileRecord.schoolId, fileRecord.id); + expect(filesStorageService.downloadFile).toHaveBeenCalledWith(fileRecord); }); it('should return correct response', async () => { 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 a8ca43534ea..610f54d8d3b 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 @@ -3,14 +3,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordsWithParams = () => { @@ -68,6 +69,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); 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 85c1597f712..05961b33339 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 @@ -4,15 +4,16 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams, SingleFileParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordsWithParams = () => { @@ -83,6 +84,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); 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 e617bbd330a..75621cd82da 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 @@ -3,14 +3,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { RenameFileParams, ScanResultParams, SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; const buildFileRecordWithParams = () => { @@ -62,6 +63,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); 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 bade1cb18c4..78348078565 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 @@ -5,45 +5,33 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; -import { fileRecordFactory, setupEntities } from '@shared/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; +import { AxiosHeadersKeyValue, axiosResponseFactory, fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { Action, AuthorizationService } from '@src/modules/authorization'; -import { AxiosRequestConfig, AxiosResponse, AxiosResponseHeaders } from 'axios'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { Request } from 'express'; import { of } from 'rxjs'; import { Readable } from 'stream'; -import { S3ClientAdapter } from '../client/s3-client.adapter'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { ErrorType } from '../error'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { FileDtoBuilder, FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; import { FilesStorageUC } from './files-storage.uc'; -const createAxiosResponse = (data: Readable, headers: AxiosResponseHeaders = {}): AxiosResponse => { - const response: AxiosResponse = { +const createAxiosResponse = (data: T, headers?: AxiosHeadersKeyValue) => + axiosResponseFactory.build({ data, - status: 0, - statusText: '', headers, - config: {}, - }; - - return response; -}; + }); const createAxiosErrorResponse = (): AxiosResponse => { - const headers: AxiosResponseHeaders = {}; - const config: AxiosRequestConfig = {}; - const errorResponse: AxiosResponse = { - data: {}, + const errorResponse: AxiosResponse = axiosResponseFactory.build({ status: 404, - statusText: 'errorText', - headers, - config, - request: {}, - }; + }); return errorResponse; }; @@ -117,6 +105,10 @@ describe('FilesStorageUC upload methods', () => { provide: HttpService, useValue: createMock(), }, + { + provide: PreviewService, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts index 4a024dba767..fa6a27202de 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts @@ -15,6 +15,7 @@ import { DownloadFileParams, FileRecordParams, FileUrlParams, + PreviewParams, RenameFileParams, ScanResultParams, SingleFileParams, @@ -22,9 +23,10 @@ import { import { FileRecord, FileRecordParentType } from '../entity'; import { ErrorType } from '../error'; import { FileStorageAuthorizationContext } from '../files-storage.const'; -import { IGetFileResponse } from '../interface'; +import { GetFileResponse } from '../interface'; import { FileDtoBuilder, FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; +import { PreviewService } from '../service/preview.service'; @Injectable() export class FilesStorageUC { @@ -32,7 +34,8 @@ export class FilesStorageUC { private logger: LegacyLogger, private readonly authorizationService: AuthorizationService, private readonly httpService: HttpService, - private readonly filesStorageService: FilesStorageService + private readonly filesStorageService: FilesStorageService, + private readonly previewService: PreviewService ) { this.logger.setContext(FilesStorageUC.name); } @@ -121,7 +124,7 @@ export class FilesStorageUC { } // download - public async download(userId: EntityId, params: DownloadFileParams, bytesRange?: string): Promise { + public async download(userId: EntityId, params: DownloadFileParams, bytesRange?: string): Promise { const singleFileParams = FilesStorageMapper.mapToSingleFileParams(params); const fileRecord = await this.filesStorageService.getFileRecord(singleFileParams); const { parentType, parentId } = fileRecord.getParentInfo(); @@ -131,17 +134,35 @@ export class FilesStorageUC { return this.filesStorageService.download(fileRecord, params, bytesRange); } - public async downloadBySecurityToken(token: string): Promise { + public async downloadBySecurityToken(token: string): Promise { const fileRecord = await this.filesStorageService.getFileRecordBySecurityCheckRequestToken(token); - const res = await this.filesStorageService.downloadFile(fileRecord.getSchoolId(), fileRecord.id); + const res = await this.filesStorageService.downloadFile(fileRecord); return res; } + public async downloadPreview( + userId: EntityId, + params: DownloadFileParams, + previewParams: PreviewParams, + bytesRange?: string + ): Promise { + const singleFileParams = FilesStorageMapper.mapToSingleFileParams(params); + const fileRecord = await this.filesStorageService.getFileRecord(singleFileParams); + const { parentType, parentId } = fileRecord.getParentInfo(); + + await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.read); + + const result = this.previewService.getPreview(fileRecord, params, previewParams, bytesRange); + + return result; + } + // delete public async deleteFilesOfParent(userId: EntityId, params: FileRecordParams): Promise> { await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.delete); const [fileRecords, count] = await this.filesStorageService.deleteFilesOfParent(params.parentId); + await this.previewService.deletePreviews(fileRecords); return [fileRecords, count]; } @@ -152,6 +173,7 @@ export class FilesStorageUC { await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.delete); await this.filesStorageService.delete([fileRecord]); + await this.previewService.deletePreviews([fileRecord]); return fileRecord; } 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 8eaa156c6c9..9f64e07129a 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,11 +2,12 @@ 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 { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; import { Readable } from 'stream'; import request from 'supertest'; import { FwuLearningContentsTestModule } from '../../fwu-learning-contents-test.module'; +import { FWU_CONTENT_S3_CONNECTION } from '../../fwu-learning-contents.config'; class API { constructor(private app: INestApplication) { @@ -37,13 +38,13 @@ describe('FwuLearningContents Controller (api)', () => { return true; }, }) - .overrideProvider(S3ClientAdapter) + .overrideProvider(FWU_CONTENT_S3_CONNECTION) .useValue(createMock()) .compile(); app = module.createNestApplication(); await app.init(); - s3ClientAdapter = module.get(S3ClientAdapter); + s3ClientAdapter = module.get(FWU_CONTENT_S3_CONNECTION); api = new API(app); }); 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 758dcc17444..4e633ca4e1b 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 @@ -5,14 +5,14 @@ import { Account, Role, School, SchoolYear, System, 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 { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; import { AuthorizationModule } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../files-storage/client/s3-client.adapter'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; -import { config } from './fwu-learning-contents.config'; +import { config, s3Config } from './fwu-learning-contents.config'; import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; const imports = [ @@ -24,9 +24,10 @@ const imports = [ CoreModule, LoggerModule, RabbitMQWrapperTestModule, + S3ClientModule.register([s3Config]), ]; const controllers = [FwuLearningContentsController]; -const providers = [FwuLearningContentsUc, S3ClientAdapter]; +const providers = [FwuLearningContentsUc]; @Module({ imports, controllers, 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 a1bc2f2fe5e..56ae93e0205 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,7 +1,10 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import { S3Config } from './interface/config'; +import { S3Config } from '@shared/infra/s3-client'; + +export const FWU_CONTENT_S3_CONNECTION = 'FWU_CONTENT_S3_CONNECTION'; export const s3Config: S3Config = { + connectionName: FWU_CONTENT_S3_CONNECTION, endpoint: Configuration.get('FWU_CONTENT__S3_ENDPOINT') as string, region: Configuration.get('FWU_CONTENT__S3_REGION') as string, bucket: Configuration.get('FWU_CONTENT__S3_BUCKET') as string, 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 8ebbff1054c..55c083b3061 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 @@ -1,46 +1,19 @@ -import { S3Client } from '@aws-sdk/client-s3'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { HttpModule } from '@nestjs/axios'; -import { Module, NotFoundException, Scope } from '@nestjs/common'; +import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Account, Role, School, SchoolYear, System, User } from '@shared/domain'; +import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; +import { S3ClientModule } from '@shared/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'; -import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; import { AuthorizationModule } from '@src/modules/authorization'; -import { S3ClientAdapter } from '../files-storage/client/s3-client.adapter'; +import { AuthenticationModule } from '../authentication/authentication.module'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; import { config, s3Config } from './fwu-learning-contents.config'; -import { S3Config } from './interface/config'; import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; -import { FilesStorageAMQPModule } from '../files-storage/files-storage-amqp.module'; - -const providers = [ - { - provide: 'S3_Client', - scope: Scope.REQUEST, - useFactory: (configProvider: S3Config) => - new S3Client({ - region: configProvider.region, - credentials: { - accessKeyId: configProvider.accessKeyId, - secretAccessKey: configProvider.secretAccessKey, - }, - endpoint: configProvider.endpoint, - forcePathStyle: true, - tls: true, - }), - inject: ['S3_Config'], - }, - { - provide: 'S3_Config', - useValue: s3Config, - }, - FwuLearningContentsUc, - S3ClientAdapter, -]; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -55,7 +28,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { CoreModule, LoggerModule, HttpModule, - FilesStorageAMQPModule, + RabbitMQWrapperModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -68,8 +41,9 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { // debug: true, // use it for locally debugging of querys }), ConfigModule.forRoot(createConfigModuleOptions(config)), + S3ClientModule.register([s3Config]), ], controllers: [FwuLearningContentsController], - providers, + providers: [FwuLearningContentsUc], }) export class FwuLearningContentsModule {} 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 706f2981fd0..80240e0ea8e 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,11 +1,9 @@ -import { S3Client } from '@aws-sdk/client-s3'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; import { Readable } from 'stream'; -import { S3Config } from '../interface/config'; +import { FWU_CONTENT_S3_CONNECTION } from '../fwu-learning-contents.config'; import { FwuLearningContentsUc } from './fwu-learning-contents.uc'; describe('FwuLearningContentsUC', () => { @@ -22,22 +20,15 @@ describe('FwuLearningContentsUC', () => { }; let module: TestingModule; let fwuLearningContentsUc: FwuLearningContentsUc; - let s3client: DeepMocked; + let s3client: DeepMocked; beforeAll(async () => { - const { config } = createParameter(); - module = await Test.createTestingModule({ providers: [ FwuLearningContentsUc, - S3ClientAdapter, - { - provide: 'S3_Client', - useValue: createMock(), - }, { - provide: 'S3_Config', - useValue: createMock(config), + provide: FWU_CONTENT_S3_CONNECTION, + useValue: createMock(), }, { provide: LegacyLogger, @@ -47,7 +38,7 @@ describe('FwuLearningContentsUC', () => { }).compile(); fwuLearningContentsUc = module.get(FwuLearningContentsUc); - s3client = module.get('S3_Client'); + s3client = module.get(FWU_CONTENT_S3_CONNECTION); }); beforeEach(() => { @@ -75,7 +66,7 @@ describe('FwuLearningContentsUC', () => { }; // @ts-expect-error Testcase - s3client.send.mockResolvedValueOnce(resultObj); + s3client.get.mockResolvedValueOnce(resultObj); return { pathToFile, config }; }; @@ -88,48 +79,12 @@ describe('FwuLearningContentsUC', () => { expect(result).toBeInstanceOf(Object); }); - it('should call send() of client', async () => { - const { pathToFile, config } = setup(); + it('should call get() of client', async () => { + const { pathToFile } = setup(); await fwuLearningContentsUc.get(pathToFile); - expect(s3client.send).toBeCalledWith( - expect.objectContaining({ - input: { - Bucket: config.bucket, - Key: pathToFile, - }, - }) - ); - }); - }); - - describe('when client throws error', () => { - const setup = (error: ErrorType) => { - const { pathToFile } = createParameter(); - - // @ts-expect-error Testcase - s3client.send.mockRejectedValueOnce(error); - - return { error, pathToFile }; - }; - - it('should throw NotFoundException', async () => { - const { pathToFile } = setup({ name: 'NoSuchKey', stack: 'NoSuchKey at ...' }); - - await expect(fwuLearningContentsUc.get(pathToFile)).rejects.toThrowError(InternalServerErrorException); - }); - - it('should throw error', async () => { - const { pathToFile } = setup({ name: 'UnknownError' }); - - await expect(fwuLearningContentsUc.get(pathToFile)).rejects.toThrowError(InternalServerErrorException); - }); - - it('should throw error', async () => { - const { pathToFile } = setup('Not an Error object'); - - await expect(fwuLearningContentsUc.get(pathToFile)).rejects.toThrowError(InternalServerErrorException); + expect(s3client.get).toBeCalledWith(pathToFile, undefined); }); }); }); 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 065abf554fe..afab92d46a5 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,10 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; -import { S3ClientAdapter } from '../../files-storage/client/s3-client.adapter'; +import { FWU_CONTENT_S3_CONNECTION } from '../fwu-learning-contents.config'; @Injectable() export class FwuLearningContentsUc { - constructor(private logger: LegacyLogger, private readonly storageClient: S3ClientAdapter) { + constructor( + private logger: LegacyLogger, + @Inject(FWU_CONTENT_S3_CONNECTION) private readonly storageClient: S3ClientAdapter + ) { this.logger.setContext(FwuLearningContentsUc.name); } diff --git a/apps/server/src/modules/group/domain/group-types.ts b/apps/server/src/modules/group/domain/group-types.ts new file mode 100644 index 00000000000..fa1311a0495 --- /dev/null +++ b/apps/server/src/modules/group/domain/group-types.ts @@ -0,0 +1,3 @@ +export enum GroupTypes { + CLASS = 'class', +} diff --git a/apps/server/src/modules/group/domain/group-user.ts b/apps/server/src/modules/group/domain/group-user.ts new file mode 100644 index 00000000000..8f098ebc660 --- /dev/null +++ b/apps/server/src/modules/group/domain/group-user.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain'; + +export class GroupUser { + userId: EntityId; + + roleId: EntityId; + + constructor(props: GroupUser) { + this.userId = props.userId; + this.roleId = props.roleId; + } +} diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts new file mode 100644 index 00000000000..8ebd8b7ab04 --- /dev/null +++ b/apps/server/src/modules/group/domain/group.ts @@ -0,0 +1,24 @@ +import { EntityId, ExternalSource } from '@shared/domain'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { GroupTypes } from './group-types'; +import { GroupUser } from './group-user'; + +export interface GroupProps extends AuthorizableObject { + id: EntityId; + + name: string; + + type: GroupTypes; + + validFrom?: Date; + + validUntil?: Date; + + externalSource?: ExternalSource; + + users: GroupUser[]; + + organizationId?: string; +} + +export class Group extends DomainObject {} diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts new file mode 100644 index 00000000000..f140dc330a6 --- /dev/null +++ b/apps/server/src/modules/group/domain/index.ts @@ -0,0 +1,3 @@ +export * from './group'; +export * from './group-user'; +export * from './group-types'; diff --git a/apps/server/src/modules/group/entity/group-user.entity.ts b/apps/server/src/modules/group/entity/group-user.entity.ts new file mode 100644 index 00000000000..e202de7a400 --- /dev/null +++ b/apps/server/src/modules/group/entity/group-user.entity.ts @@ -0,0 +1,22 @@ +import { Embeddable, ManyToOne } from '@mikro-orm/core'; +import { Role, User } from '@shared/domain/entity'; + +export interface GroupUserEntityProps { + user: User; + + role: Role; +} + +@Embeddable() +export class GroupUserEntity { + @ManyToOne(() => User) + user: User; + + @ManyToOne(() => Role) + role: Role; + + constructor(props: GroupUserEntityProps) { + this.user = props.user; + this.role = props.role; + } +} diff --git a/apps/server/src/modules/group/entity/group-valid-period.entity.ts b/apps/server/src/modules/group/entity/group-valid-period.entity.ts new file mode 100644 index 00000000000..f3f656241c6 --- /dev/null +++ b/apps/server/src/modules/group/entity/group-valid-period.entity.ts @@ -0,0 +1,21 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +export interface GroupValidPeriodEntityProps { + from: Date; + + until: Date; +} + +@Embeddable() +export class GroupValidPeriodEntity { + @Property() + from: Date; + + @Property() + until: Date; + + constructor(props: GroupValidPeriodEntityProps) { + this.from = props.from; + this.until = props.until; + } +} diff --git a/apps/server/src/modules/group/entity/group.entity.ts b/apps/server/src/modules/group/entity/group.entity.ts new file mode 100644 index 00000000000..0abd954c329 --- /dev/null +++ b/apps/server/src/modules/group/entity/group.entity.ts @@ -0,0 +1,61 @@ +import { Embedded, Entity, Enum, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { ExternalSourceEntity } from '@shared/domain/entity/external-source.entity'; +import { School } from '@shared/domain/entity/school.entity'; +import { EntityId } from '@shared/domain/types'; +import { GroupUserEntity } from './group-user.entity'; +import { GroupValidPeriodEntity } from './group-valid-period.entity'; + +export enum GroupEntityTypes { + CLASS = 'class', +} + +export interface GroupEntityProps { + id?: EntityId; + + name: string; + + type: GroupEntityTypes; + + externalSource?: ExternalSourceEntity; + + validPeriod?: GroupValidPeriodEntity; + + users: GroupUserEntity[]; + + organization?: School; +} + +@Entity({ tableName: 'groups' }) +export class GroupEntity extends BaseEntityWithTimestamps { + @Property() + name: string; + + @Enum() + type: GroupEntityTypes; + + @Embedded(() => ExternalSourceEntity, { nullable: true }) + externalSource?: ExternalSourceEntity; + + @Embedded(() => GroupValidPeriodEntity, { nullable: true }) + validPeriod?: GroupValidPeriodEntity; + + @Embedded(() => GroupUserEntity, { array: true }) + users: GroupUserEntity[]; + + @ManyToOne(() => School, { nullable: true }) + organization?: School; + + constructor(props: GroupEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.name = props.name; + this.type = props.type; + this.externalSource = props.externalSource; + this.validPeriod = props.validPeriod; + this.users = props.users; + this.organization = props.organization; + } +} diff --git a/apps/server/src/modules/group/entity/index.ts b/apps/server/src/modules/group/entity/index.ts new file mode 100644 index 00000000000..1aff7d6deeb --- /dev/null +++ b/apps/server/src/modules/group/entity/index.ts @@ -0,0 +1,3 @@ +export * from './group.entity'; +export * from './group-user.entity'; +export * from './group-valid-period.entity'; diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts new file mode 100644 index 00000000000..1be422855a9 --- /dev/null +++ b/apps/server/src/modules/group/group-api.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { GroupModule } from './group.module'; + +@Module({ + imports: [GroupModule], +}) +export class GroupApiModule {} diff --git a/apps/server/src/modules/group/group.module.ts b/apps/server/src/modules/group/group.module.ts new file mode 100644 index 00000000000..58bce2070d0 --- /dev/null +++ b/apps/server/src/modules/group/group.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GroupRepo } from './repo'; +import { GroupService } from './service'; + +@Module({ + providers: [GroupRepo, GroupService], + exports: [GroupService], +}) +export class GroupModule {} diff --git a/apps/server/src/modules/group/index.ts b/apps/server/src/modules/group/index.ts new file mode 100644 index 00000000000..01004d5e52e --- /dev/null +++ b/apps/server/src/modules/group/index.ts @@ -0,0 +1,3 @@ +export * from './group.module'; +export * from './domain'; +export { GroupService } from './service'; diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts new file mode 100644 index 00000000000..7b9802e8d46 --- /dev/null +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -0,0 +1,98 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ExternalSource, ExternalSourceEntity, Role, School, System, User } from '@shared/domain'; +import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; +import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupUserEntity, GroupValidPeriodEntity } from '../entity'; + +const GroupEntityTypesToGroupTypesMapping: Record = { + [GroupEntityTypes.CLASS]: GroupTypes.CLASS, +}; + +const GroupTypesToGroupEntityTypesMapping: Record = { + [GroupTypes.CLASS]: GroupEntityTypes.CLASS, +}; + +export class GroupDomainMapper { + static mapDomainObjectToEntityProperties(group: Group, em: EntityManager): GroupEntityProps { + const props: GroupProps = group.getProps(); + + let validPeriod: GroupValidPeriodEntity | undefined; + if (props.validFrom && props.validUntil) { + validPeriod = new GroupValidPeriodEntity({ + from: props.validFrom, + until: props.validUntil, + }); + } + + const mapped: GroupEntityProps = { + id: props.id, + name: props.name, + type: GroupTypesToGroupEntityTypesMapping[props.type], + externalSource: props.externalSource + ? this.mapExternalSourceToExternalSourceEntity(props.externalSource, em) + : undefined, + users: props.users.map( + (groupUser): GroupUserEntity => GroupDomainMapper.mapGroupUserToGroupUserEntity(groupUser, em) + ), + validPeriod, + organization: props.organizationId ? em.getReference(School, props.organizationId) : undefined, + }; + + return mapped; + } + + static mapEntityToDomainObjectProperties(entity: GroupEntity): GroupProps { + const mapped: GroupProps = { + id: entity.id, + users: entity.users.map((groupUser): GroupUser => this.mapGroupUserEntityToGroupUser(groupUser)), + validFrom: entity.validPeriod ? entity.validPeriod.from : undefined, + validUntil: entity.validPeriod ? entity.validPeriod.until : undefined, + externalSource: entity.externalSource + ? this.mapExternalSourceEntityToExternalSource(entity.externalSource) + : undefined, + type: GroupEntityTypesToGroupTypesMapping[entity.type], + name: entity.name, + organizationId: entity.organization?.id, + }; + + return mapped; + } + + static mapExternalSourceToExternalSourceEntity( + externalSource: ExternalSource, + em: EntityManager + ): ExternalSourceEntity { + const mapped = new ExternalSourceEntity({ + externalId: externalSource.externalId, + system: em.getReference(System, externalSource.systemId), + }); + + return mapped; + } + + static mapExternalSourceEntityToExternalSource(entity: ExternalSourceEntity): ExternalSource { + const mapped = new ExternalSource({ + externalId: entity.externalId, + systemId: entity.system.id, + }); + + return mapped; + } + + static mapGroupUserToGroupUserEntity(groupUser: GroupUser, em: EntityManager): GroupUserEntity { + const mapped = new GroupUserEntity({ + user: em.getReference(User, groupUser.userId), + role: em.getReference(Role, groupUser.roleId), + }); + + return mapped; + } + + static mapGroupUserEntityToGroupUser(entity: GroupUserEntity): GroupUser { + const mapped = new GroupUser({ + userId: entity.user.id, + roleId: entity.role.id, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts new file mode 100644 index 00000000000..a7c7454dae4 --- /dev/null +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -0,0 +1,264 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExternalSource } from '@shared/domain'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections, groupEntityFactory, groupFactory } from '@shared/testing'; +import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; +import { GroupEntity } from '../entity'; +import { GroupRepo } from './group.repo'; + +describe('GroupRepo', () => { + let module: TestingModule; + let repo: GroupRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [GroupRepo], + }).compile(); + + repo = module.get(GroupRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findById', () => { + describe('when an entity with the id exists', () => { + const setup = async () => { + const group: GroupEntity = groupEntityFactory.buildWithId(); + + await em.persistAndFlush(group); + em.clear(); + + return { + group, + }; + }; + + it('should return the group', async () => { + const { group } = await setup(); + + const result: Group | null = await repo.findById(group.id); + + expect(result?.getProps()).toEqual({ + id: group.id, + name: group.name, + type: GroupTypes.CLASS, + externalSource: new ExternalSource({ + externalId: group.externalSource?.externalId ?? '', + systemId: group.externalSource?.system.id ?? '', + }), + users: [ + new GroupUser({ + userId: group.users[0].user.id, + roleId: group.users[0].role.id, + }), + new GroupUser({ + userId: group.users[1].user.id, + roleId: group.users[1].role.id, + }), + ], + organizationId: group.organization?.id, + validFrom: group.validPeriod?.from, + validUntil: group.validPeriod?.until, + }); + }); + }); + + describe('when no entity with the id exists', () => { + it('should return null', async () => { + const result: Group | null = await repo.findById(new ObjectId().toHexString()); + + expect(result).toBeNull(); + }); + }); + }); + + describe('save', () => { + describe('when a new object is provided', () => { + const setup = () => { + const groupId = new ObjectId().toHexString(); + + const group: Group = groupFactory.build({ id: groupId }); + + return { + group, + groupId, + }; + }; + + it('should create a new entity', async () => { + const { group, groupId } = setup(); + + await repo.save(group); + + await expect(em.findOneOrFail(GroupEntity, groupId)).resolves.toBeDefined(); + }); + + it('should return the object', async () => { + const { group } = setup(); + + const result: Group = await repo.save(group); + + expect(result).toEqual(group); + }); + }); + + describe('when an entity with the id exists', () => { + const setup = async () => { + const groupId = new ObjectId().toHexString(); + const groupEntity: GroupEntity = groupEntityFactory.buildWithId({ name: 'Initial Name' }, groupId); + + await em.persistAndFlush(groupEntity); + em.clear(); + + const newName = 'New Name'; + const group: Group = groupFactory.build({ id: groupId, name: newName }); + + return { + groupEntity, + group, + groupId, + newName, + }; + }; + + it('should update the entity', async () => { + const { group, groupId, newName } = await setup(); + + await repo.save(group); + + await expect(em.findOneOrFail(GroupEntity, groupId)).resolves.toEqual( + expect.objectContaining({ name: newName }) + ); + }); + + it('should return the object', async () => { + const { group } = await setup(); + + const result: Group = await repo.save(group); + + expect(result).toEqual(group); + }); + }); + }); + + describe('delete', () => { + describe('when an entity exists', () => { + const setup = async () => { + const groupId = new ObjectId().toHexString(); + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(undefined, groupId); + + await em.persistAndFlush(groupEntity); + em.clear(); + + const group: Group = groupFactory.build({ id: groupId }); + + return { + group, + groupId, + }; + }; + + it('should delete the entity', async () => { + const { group, groupId } = await setup(); + + await repo.delete(group); + + expect(await em.findOne(GroupEntity, groupId)).toBeNull(); + }); + + it('should return true', async () => { + const { group } = await setup(); + + const result: boolean = await repo.delete(group); + + expect(result).toEqual(true); + }); + }); + + describe('when no entity exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + return { + group, + }; + }; + + it('should return false', async () => { + const { group } = setup(); + + const result: boolean = await repo.delete(group); + + expect(result).toEqual(false); + }); + }); + }); + + describe('findByExternalSource', () => { + describe('when an entity with the external source exists', () => { + const setup = async () => { + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(); + + await em.persistAndFlush(groupEntity); + em.clear(); + + return { + groupEntity, + }; + }; + + it('should return the group', async () => { + const { groupEntity } = await setup(); + + const result: Group | null = await repo.findByExternalSource( + groupEntity.externalSource?.externalId ?? '', + groupEntity.externalSource?.system.id ?? '' + ); + + expect(result?.getProps()).toEqual({ + id: groupEntity.id, + name: groupEntity.name, + type: GroupTypes.CLASS, + externalSource: new ExternalSource({ + externalId: groupEntity.externalSource?.externalId ?? '', + systemId: groupEntity.externalSource?.system.id ?? '', + }), + users: [ + new GroupUser({ + userId: groupEntity.users[0].user.id, + roleId: groupEntity.users[0].role.id, + }), + new GroupUser({ + userId: groupEntity.users[1].user.id, + roleId: groupEntity.users[1].role.id, + }), + ], + organizationId: groupEntity.organization?.id, + validFrom: groupEntity.validPeriod?.from, + validUntil: groupEntity.validPeriod?.until, + }); + }); + }); + + describe('when no entity with the external source exists', () => { + it('should return null', async () => { + const result: Group | null = await repo.findByExternalSource( + new ObjectId().toHexString(), + new ObjectId().toHexString() + ); + + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts new file mode 100644 index 00000000000..a5477908d6c --- /dev/null +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -0,0 +1,81 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Group, GroupProps } from '../domain'; +import { GroupEntity, GroupEntityProps } from '../entity'; +import { GroupDomainMapper } from './group-domain.mapper'; + +@Injectable() +export class GroupRepo { + constructor(private readonly em: EntityManager) {} + + async findById(id: EntityId): Promise { + const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id }); + + if (!entity) { + return null; + } + + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + const domainObject: Group = new Group(props); + + return domainObject; + } + + async findByExternalSource(externalId: string, systemId: EntityId): Promise { + const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { + externalSource: { + externalId, + system: systemId, + }, + }); + + if (!entity) { + return null; + } + + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + const domainObject: Group = new Group(props); + + return domainObject; + } + + async save(domainObject: Group): Promise { + const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); + + const newEntity: GroupEntity = new GroupEntity(entityProps); + + const existingEntity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); + + let savedEntity: GroupEntity; + if (existingEntity) { + savedEntity = this.em.assign(existingEntity, newEntity); + } else { + this.em.persist(newEntity); + + savedEntity = newEntity; + } + + await this.em.flush(); + + const savedProps: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(savedEntity); + + const savedDomainObject: Group = new Group(savedProps); + + return savedDomainObject; + } + + async delete(domainObject: Group): Promise { + const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); + + if (!entity) { + return false; + } + + await this.em.removeAndFlush(entity); + + return true; + } +} diff --git a/apps/server/src/modules/group/repo/index.ts b/apps/server/src/modules/group/repo/index.ts new file mode 100644 index 00000000000..6933b9d64c5 --- /dev/null +++ b/apps/server/src/modules/group/repo/index.ts @@ -0,0 +1 @@ +export * from './group.repo'; diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts new file mode 100644 index 00000000000..3bcc8fa287e --- /dev/null +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -0,0 +1,207 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { groupFactory } from '@shared/testing'; +import { Group } from '../domain'; +import { GroupRepo } from '../repo'; +import { GroupService } from './group.service'; + +describe('GroupService', () => { + let module: TestingModule; + let service: GroupService; + + let groupRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + GroupService, + { + provide: GroupRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(GroupService); + groupRepo = module.get(GroupRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findById', () => { + describe('when a group with the id exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.findById.mockResolvedValue(group); + + return { + group, + }; + }; + + it('should return the group', async () => { + const { group } = setup(); + + const result: Group = await service.findById(group.id); + + expect(result).toEqual(group); + }); + }); + + describe('when a group with the id does not exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.findById.mockResolvedValue(null); + + return { + group, + }; + }; + + it('should throw NotFoundLoggableException', async () => { + const { group } = setup(); + + const func = () => service.findById(group.id); + + await expect(func).rejects.toThrow(NotFoundLoggableException); + }); + }); + }); + + describe('tryFindById', () => { + describe('when a group with the id exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.findById.mockResolvedValue(group); + + return { + group, + }; + }; + + it('should return the group', async () => { + const { group } = setup(); + + const result: Group | null = await service.tryFindById(group.id); + + expect(result).toEqual(group); + }); + }); + + describe('when a group with the id does not exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.findById.mockResolvedValue(null); + + return { + group, + }; + }; + + it('should return null', async () => { + const { group } = setup(); + + const result: Group | null = await service.tryFindById(group.id); + + expect(result).toBeNull(); + }); + }); + }); + + describe('save', () => { + describe('when saving a group', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.save.mockResolvedValue(group); + + return { + group, + }; + }; + + it('should call repo.save', async () => { + const { group } = setup(); + + await service.save(group); + + expect(groupRepo.save).toHaveBeenCalledWith(group); + }); + + it('should return the group', async () => { + const { group } = setup(); + + const result: Group | null = await service.save(group); + + expect(result).toEqual(group); + }); + }); + }); + + describe('delete', () => { + describe('when saving a group', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + return { + group, + }; + }; + + it('should call repo.delete', async () => { + const { group } = setup(); + + await service.delete(group); + + expect(groupRepo.delete).toHaveBeenCalledWith(group); + }); + }); + }); + + describe('findByExternalSource', () => { + describe('when a group with the externalId exists', () => { + const setup = () => { + const group: Group = groupFactory.build(); + + groupRepo.findByExternalSource.mockResolvedValue(group); + + return { + group, + }; + }; + + it('should return the group', async () => { + const { group } = setup(); + + const result: Group | null = await service.findByExternalSource('externalId', 'systemId'); + + expect(result).toEqual(group); + }); + }); + + describe('when a group with the externalId does not exists', () => { + const setup = () => { + groupRepo.findByExternalSource.mockResolvedValue(null); + }; + + it('should return null', async () => { + setup(); + + const result: Group | null = await service.findByExternalSource('externalId', 'systemId'); + + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts new file mode 100644 index 00000000000..030f3eb6685 --- /dev/null +++ b/apps/server/src/modules/group/service/group.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId } from '@shared/domain'; +import { AuthorizationLoaderServiceGeneric } from '@src/modules/authorization'; +import { Group } from '../domain'; +import { GroupRepo } from '../repo'; + +@Injectable() +export class GroupService implements AuthorizationLoaderServiceGeneric { + constructor(private readonly groupRepo: GroupRepo) {} + + async findById(id: EntityId): Promise { + const group: Group | null = await this.groupRepo.findById(id); + + if (!group) { + throw new NotFoundLoggableException(Group.name, 'id', id); + } + + return group; + } + + async findByExternalSource(externalId: string, systemId: EntityId): Promise { + const group: Group | null = await this.groupRepo.findByExternalSource(externalId, systemId); + + return group; + } + + async tryFindById(id: EntityId): Promise { + const group: Group | null = await this.groupRepo.findById(id); + + return group; + } + + async save(group: Group): Promise { + const savedGroup: Group = await this.groupRepo.save(group); + + return savedGroup; + } + + async delete(group: Group): Promise { + await this.groupRepo.delete(group); + } +} diff --git a/apps/server/src/modules/group/service/index.ts b/apps/server/src/modules/group/service/index.ts new file mode 100644 index 00000000000..ee9dd3ff064 --- /dev/null +++ b/apps/server/src/modules/group/service/index.ts @@ -0,0 +1 @@ +export * from './group.service'; diff --git a/apps/server/src/modules/h5p-editor/console/h5p-editor.console.ts b/apps/server/src/modules/h5p-editor/console/h5p-editor.console.ts deleted file mode 100644 index 995970251b7..00000000000 --- a/apps/server/src/modules/h5p-editor/console/h5p-editor.console.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ConsoleWriterService } from '@shared/infra/console/console-writer/console-writer.service'; -import { Command, Console } from 'nestjs-console'; -import { H5PEditorUc } from '../uc/h5p.uc'; - -@Console({ command: 'h5p', description: 'h5p-editor commands' }) -export class DatabaseManagementConsole { - constructor(private consoleWriter: ConsoleWriterService, private h5pEditorUc: H5PEditorUc) {} - - @Command({ - command: 'test', - options: [], - description: 'test dummy command', - }) - syncIndexes(): void { - this.h5pEditorUc.dummyFunction(); - this.consoleWriter.info('END'); - } -} 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 index 989166ef6a7..543db38ebbf 100644 --- 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 @@ -3,9 +3,10 @@ import { H5PAjaxEndpoint } from '@lumieducation/h5p-server'; import { EntityManager } from '@mikro-orm/core'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; -import { H5PEditorTestModule } from '@src/modules/h5p-editor/h5p-editor-test.module'; +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; @@ -18,9 +19,9 @@ describe('H5PEditor Controller (api)', () => { const module = await Test.createTestingModule({ imports: [H5PEditorTestModule], }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5PAjaxEndpoint) .useValue(createMock()) 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 index 4fe7db23392..9f3b0017d08 100644 --- 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 @@ -1,15 +1,16 @@ -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import request from 'supertest'; +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 { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +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 { @@ -49,9 +50,9 @@ describe('H5PEditor Controller (api)', () => { return true; }, }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5PEditorUc) .useValue(createMock()) 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 index 91df126ce9c..41ca9f98457 100644 --- 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 @@ -3,12 +3,13 @@ import { ContentMetadata } from '@lumieducation/h5p-server/build/src/ContentMeta import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; -import { H5PEditorTestModule } from '@src/modules/h5p-editor/h5p-editor-test.module'; import { Readable } from 'stream'; -import { LibraryStorage, TemporaryFileStorage, ContentStorage } from '../../service'; import { TemporaryFile } 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'; describe('H5PEditor Controller (api)', () => { let app: INestApplication; @@ -23,9 +24,9 @@ describe('H5PEditor Controller (api)', () => { const module = await Test.createTestingModule({ imports: [H5PEditorTestModule], }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(ContentStorage) .useValue(createMock()) 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 index bc4d6c17cda..737266f300d 100644 --- 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 @@ -1,15 +1,16 @@ -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import request from 'supertest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; -import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +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 { @@ -18,11 +19,11 @@ class API { } async emptyEditor() { - return request(this.app.getHttpServer()).get(`/h5p-editor/edit`); + return request(this.app.getHttpServer()).get(`/h5p-editor/edit/de`); } async editH5pContent(contentId: string) { - return request(this.app.getHttpServer()).get(`/h5p-editor/edit/${contentId}`); + return request(this.app.getHttpServer()).get(`/h5p-editor/edit/${contentId}/de`); } } @@ -67,9 +68,9 @@ describe('H5PEditor Controller (api)', () => { return true; }, }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5PEditorUc) .useValue(createMock()) 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 index 3360b0b6f8a..708dfef968a 100644 --- 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 @@ -1,15 +1,17 @@ -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import request from 'supertest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { IPlayerModel } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; -import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +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 { @@ -26,7 +28,8 @@ const setup = () => { const contentId = new ObjectId(0).toString(); const notExistingContentId = new ObjectId(1).toString(); - const playerResult = { + // @ts-expect-error partial object + const playerResult: IPlayerModel = { contentId, dependencies: [], downloadPath: '', @@ -57,9 +60,9 @@ describe('H5PEditor Controller (api)', () => { return true; }, }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5PEditorUc) .useValue(createMock()) @@ -94,7 +97,6 @@ describe('H5PEditor Controller (api)', () => { describe('with valid request params', () => { it('should return 200 status', async () => { const { contentId, playerResult } = setup(); - // @ts-expect-error partial object h5PEditorUc.getH5pPlayer.mockResolvedValueOnce(playerResult); const response = await api.getPlayer(contentId); expect(response.status).toEqual(200); 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 index 1e1caaa7e06..c51369fdb12 100644 --- 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 @@ -1,16 +1,17 @@ -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import request from 'supertest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; +import { IContentMetadata } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; -import { IContentMetadata } from '@lumieducation/h5p-server'; -import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +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'; const setup = () => { @@ -81,9 +82,9 @@ describe('H5PEditor Controller (api)', () => { return true; }, }) - .overrideProvider('S3ClientAdapter_Content') + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) - .overrideProvider('S3ClientAdapter_Libraries') + .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5PEditorUc) .useValue(createMock()) 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 index af92544d272..1c3df77ff50 100644 --- 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 @@ -19,7 +19,7 @@ class ContentBodyParams { @ApiProperty() @IsString() @IsOptional() - field!: string; // Todo: Reason + field!: string; } class LibraryParametersBodyParams { @@ -30,6 +30,10 @@ class LibraryParametersBodyParams { export type AjaxPostBodyParams = LibrariesBodyParams | ContentBodyParams | LibraryParametersBodyParams | undefined; +/** + * 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) { @@ -43,7 +47,7 @@ export class AjaxPostBodyParamsTransformPipe implements PipeTransform { } else if ('libraryParameters' in value) { transformed = plainToClass(LibraryParametersBodyParams, value); } else { - return undefined; // Todo Check this + return undefined; } const validationResult = await validate(transformed); @@ -59,14 +63,3 @@ export class AjaxPostBodyParamsTransformPipe implements PipeTransform { return undefined; } } - -export const AjaxPostBodyParamsFilesInterceptor = AnyFilesInterceptor({ - limits: { files: 2 }, - fileFilter(_req, file, callback) { - if (file.fieldname === 'file' || file.fieldname === 'h5p') { - callback(null, true); - } else { - callback(new BadRequestException('File not allowed'), false); - } - }, -}); 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 index 0cdb4876454..e8e4b404faa 100644 --- 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 @@ -9,5 +9,5 @@ export class ContentFileUrlParams { @ApiProperty() @IsString() @IsNotEmpty() - file!: string; + filename!: string; } 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 index 27a00d078d4..a8c6d8c466d 100644 --- 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 @@ -1,8 +1,9 @@ import { IContentMetadata } from '@lumieducation/h5p-server'; import { ApiProperty } from '@nestjs/swagger'; import { SanitizeHtml } from '@shared/controller'; -import { LanguageType } from '@shared/domain'; +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' }) @@ -58,11 +59,18 @@ export class PostH5PContentParams { } export class PostH5PContentCreateParams { + @ApiProperty({ enum: H5PContentParentType, enumName: 'H5PContentParentType' }) + @IsEnum(H5PContentParentType) + parentType!: H5PContentParentType; + + @ApiProperty() + @IsMongoId() + parentId!: EntityId; + @ApiProperty() @IsNotEmpty() @IsObject() params!: { - // Todo params: unknown; metadata: IContentMetadata; }; 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 index f8cbc8b1a4a..28b09575537 100644 --- 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 @@ -11,9 +11,11 @@ export class H5PEditorModelResponse { @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[]; } 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 8118a653f27..3186c72e3aa 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 @@ -15,6 +15,7 @@ import { 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 } from '@src/modules/authentication'; @@ -26,7 +27,6 @@ import { H5PEditorUc } from '../uc/h5p.uc'; import { AjaxGetQueryParams, AjaxPostBodyParams, - AjaxPostBodyParamsFilesInterceptor, AjaxPostBodyParamsTransformPipe, AjaxPostQueryParams, ContentFileUrlParams, @@ -79,7 +79,7 @@ export class H5PEditorController { return content; } - @Get('content/:id/:file(*)') + @Get('content/:id/:filename(*)') async getContentFile( @Param() params: ContentFileUrlParams, @Req() req: Request, @@ -88,23 +88,12 @@ export class H5PEditorController { ) { const { data, contentType, contentLength, contentRange } = await this.h5pEditorUc.getContentFile( params.id, - params.file, + params.filename, req, currentUser ); - if (contentRange) { - const contentRangeHeader = `bytes ${contentRange.start}-${contentRange.end}/${contentLength}`; - - res.set({ - 'Accept-Ranges': 'bytes', - 'Content-Range': contentRangeHeader, - }); - - res.status(HttpStatus.PARTIAL_CONTENT); - } else { - res.status(HttpStatus.OK); - } + H5PEditorController.setRangeResponseHeaders(res, contentLength, contentRange); req.on('close', () => data.destroy()); @@ -124,18 +113,7 @@ export class H5PEditorController { currentUser ); - if (contentRange) { - const contentRangeHeader = `bytes ${contentRange.start}-${contentRange.end}/${contentLength}`; - - res.set({ - 'Accept-Ranges': 'bytes', - 'Content-Range': contentRangeHeader, - }); - - res.status(HttpStatus.PARTIAL_CONTENT); - } else { - res.status(HttpStatus.OK); - } + H5PEditorController.setRangeResponseHeaders(res, contentLength, contentRange); req.on('close', () => data.destroy()); @@ -149,14 +127,23 @@ export class H5PEditorController { } @Post('ajax') - @UseInterceptors(AjaxPostBodyParamsFilesInterceptor) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'file', maxCount: 1 }, + { name: 'h5p', maxCount: 1 }, + ]) + ) async postAjax( @Body(AjaxPostBodyParamsTransformPipe) body: AjaxPostBodyParams, @Query() query: AjaxPostQueryParams, - @UploadedFiles() files: Express.Multer.File[], - @CurrentUser() currentUser: ICurrentUser + @CurrentUser() currentUser: ICurrentUser, + @UploadedFiles() files?: { file?: Express.Multer.File[]; h5p?: Express.Multer.File[] } ) { - const result = await this.h5pEditorUc.postAjax(currentUser, query, body, files); + const contentFile = files?.file?.[0]; + const h5pFile = files?.h5p?.[0]; + + const result = await this.h5pEditorUc.postAjax(currentUser, query, body, contentFile, h5pFile); + return result; } @@ -195,7 +182,9 @@ export class H5PEditorController { currentUser, body.params.params, body.params.metadata, - body.library + body.library, + body.parentType, + body.parentId ); const saveResponse = new H5PSaveResponse(response.id, response.metadata); @@ -214,10 +203,27 @@ export class H5PEditorController { currentUser, body.params.params, body.params.metadata, - body.library + 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.ts b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts index b11f9666789..71b00007375 100644 --- a/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts +++ b/apps/server/src/modules/h5p-editor/entity/h5p-content.entity.ts @@ -1,7 +1,8 @@ -import { Embeddable, Embedded, Entity, JsonType, Property } from '@mikro-orm/core'; import { IContentMetadata, ILibraryName } from '@lumieducation/h5p-server'; -import { BaseEntity } from '@shared/domain'; 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 { @@ -102,17 +103,61 @@ export class ContentMetadata implements IContentMetadata { } } +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 BaseEntity { +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({ metadata, content }: { metadata: ContentMetadata; 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/library.entity.ts b/apps/server/src/modules/h5p-editor/entity/library.entity.ts index b9715086ca8..474a534af01 100644 --- a/apps/server/src/modules/h5p-editor/entity/library.entity.ts +++ b/apps/server/src/modules/h5p-editor/entity/library.entity.ts @@ -1,7 +1,7 @@ -import { Entity, Property } from '@mikro-orm/core'; import { IInstalledLibrary, ILibraryName } from '@lumieducation/h5p-server'; -import { BaseEntity } from '@shared/domain'; -import { IFileStats, IPath } from '@lumieducation/h5p-server/build/src/types'; +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() @@ -44,7 +44,7 @@ export class FileMetadata implements IFileStats { } @Entity({ tableName: 'h5p_library' }) -export class InstalledLibrary extends BaseEntity implements IInstalledLibrary { +export class InstalledLibrary extends BaseEntityWithTimestamps implements IInstalledLibrary { @Property() machineName: string; @@ -188,7 +188,7 @@ export class InstalledLibrary extends BaseEntity implements IInstalledLibrary { @Property() files: FileMetadata[]; - private simple_compare(a: number, b: number): number { + private static simple_compare(a: number, b: number): number { if (a > b) { return 1; } @@ -205,12 +205,12 @@ export class InstalledLibrary extends BaseEntity implements IInstalledLibrary { return this.machineName > otherLibrary.machineName ? 1 : -1; } - compareVersions(otherLibrary: ILibraryName & { patchVersion?: number }): number { - let result = this.simple_compare(this.majorVersion, otherLibrary.majorVersion); + public compareVersions(otherLibrary: ILibraryName & { patchVersion?: number }): number { + let result = InstalledLibrary.simple_compare(this.majorVersion, otherLibrary.majorVersion); if (result !== 0) { return result; } - result = this.simple_compare(this.minorVersion, otherLibrary.minorVersion); + result = InstalledLibrary.simple_compare(this.minorVersion, otherLibrary.minorVersion); if (result !== 0) { return result; } @@ -223,66 +223,36 @@ export class InstalledLibrary extends BaseEntity implements IInstalledLibrary { if (otherLibrary.patchVersion === undefined) { return 1; } - return this.simple_compare(this.patchVersion, otherLibrary.patchVersion); + return InstalledLibrary.simple_compare(this.patchVersion, otherLibrary.patchVersion); } - constructor( - machineName: string, - majorVersion: number, - minorVersion: number, - patchVersion: number, - restricted = false, - runnable: boolean | 0 | 1 = false, - title = '', - files: FileMetadata[] = [], - addTo?: { - content?: { types?: { text?: { regex?: string } }[] }; - editor?: { machineNames: string[]; player?: { machineNames: string[] } }; - }, - author?: string, - coreApi?: { majorVersion: number; minorVersion: number }, - description?: string, - dropLibraryCss?: { machineName: string }[], - dynamicDependencies?: LibraryName[], - editorDependencies?: LibraryName[], - embedTypes?: ('iframe' | 'div')[], - fullscreen?: 0 | 1, - h?: number, - license?: string, - metadataSettings?: { disable: 0 | 1; disableExtraTitleField: 0 | 1 }, - preloadedCss?: Path[], - preloadedDependencies?: LibraryName[], - preloadedJs?: Path[], - w?: number, - requiredExtensions?: { sharedState: number }, - state?: { snapshotSchema: boolean; opSchema: boolean; snapshotLogicChecks: boolean; opLogicChecks: boolean } - ) { + constructor(libraryMetadata: ILibraryMetadata, restricted = false, files: FileMetadata[] = []) { super(); - this.machineName = machineName; - this.majorVersion = majorVersion; - this.minorVersion = minorVersion; - this.patchVersion = patchVersion; + 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.runnable = runnable; - this.title = title; this.files = files; - this.addTo = addTo; - this.author = author; - this.coreApi = coreApi; - this.description = description; - this.dropLibraryCss = dropLibraryCss; - this.dynamicDependencies = dynamicDependencies; - this.editorDependencies = editorDependencies; - this.embedTypes = embedTypes; - this.fullscreen = fullscreen; - this.h = h; - this.license = license; - this.metadataSettings = metadataSettings; - this.preloadedCss = preloadedCss; - this.preloadedDependencies = preloadedDependencies; - this.preloadedJs = preloadedJs; - this.w = w; - this.requiredExtensions = requiredExtensions; - this.state = state; } } 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 972ed7862fc..709bdf66c85 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,17 +1,17 @@ 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 { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@shared/infra/database'; import { RabbitMQWrapperTestModule } from '@shared/infra/rabbitmq'; +import { S3ClientModule } from '@shared/infra/s3-client'; import { CoreModule } from '@src/core'; -import { LegacyLogger, LoggerModule } from '@src/core/logger'; +import { LoggerModule } from '@src/core/logger'; import { AuthenticationApiModule } from '@src/modules/authentication/authentication-api.module'; import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; import { AuthorizationModule } from '@src/modules/authorization'; import { UserModule } from '..'; import { H5PEditorController } from './controller'; import { s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; -import { H5PEditorModule, createS3ClientAdapter } from './h5p-editor.module'; +import { H5PEditorModule } from './h5p-editor.module'; import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; import { ContentStorage, @@ -33,6 +33,7 @@ const imports = [ CoreModule, LoggerModule, RabbitMQWrapperTestModule, + S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), ]; const controllers = [H5PEditorController]; const providers = [ @@ -46,24 +47,6 @@ const providers = [ ContentStorage, LibraryStorage, TemporaryFileStorage, - { - provide: 'S3Config_Content', - useValue: s3ConfigContent, - }, - { - provide: 'S3Config_Libraries', - useValue: s3ConfigLibraries, - }, - { - provide: 'S3ClientAdapter_Content', - useFactory: createS3ClientAdapter, - inject: ['S3Config_Content', LegacyLogger], - }, - { - provide: 'S3ClientAdapter_Libraries', - useFactory: createS3ClientAdapter, - inject: ['S3Config_Libraries', LegacyLogger], - }, ]; @Module({ 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 87ea841495b..f02084aa4e5 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts @@ -1,5 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import { S3Config } from './interface/config'; +import { S3Config } from '@shared/infra/s3-client'; const h5pEditorConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, @@ -10,7 +10,11 @@ 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, @@ -19,6 +23,7 @@ export const s3ConfigContent: S3Config = { }; 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, 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 7850ca0689b..e5f09ae37a9 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts @@ -1,30 +1,28 @@ -import { S3Client } from '@aws-sdk/client-s3'; 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, School, SchoolYear, System, User } from '@shared/domain'; +import { ALL_ENTITIES } from '@shared/domain'; import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; -import { LegacyLogger, Logger } from '@src/core/logger'; +import { Logger } from '@src/core/logger'; import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; import { AuthorizationModule } from '@src/modules/authorization'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; -import { H5PContent, InstalledLibrary } from './entity'; -import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; +import { S3ClientModule } from '@shared/infra/s3-client'; +import { UserModule } from '..'; import { H5PEditorController } from './controller/h5p-editor.controller'; +import { H5PContent, InstalledLibrary, TemporaryFile } from './entity'; import { config, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; -import { S3Config } from './interface/config'; -import { UserModule } from '..'; +import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; import { - H5PAjaxEndpointService, ContentStorage, - LibraryStorage, + H5PAjaxEndpointService, H5PEditorService, H5PPlayerService, + LibraryStorage, TemporaryFileStorage, } from './service'; import { H5PEditorUc } from './uc/h5p.uc'; @@ -35,20 +33,6 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), }; -export function createS3ClientAdapter(s3config: S3Config, legacyLogger: LegacyLogger) { - const s3Client = new S3Client({ - region: s3config.region, - credentials: { - accessKeyId: s3config.accessKeyId, - secretAccessKey: s3config.secretAccessKey, - }, - endpoint: s3config.endpoint, - forcePathStyle: true, - tls: true, - }); - return new S3ClientAdapter(s3Client, s3config, legacyLogger); -} - const imports = [ AuthenticationModule, AuthorizationModule, @@ -62,11 +46,13 @@ const imports = [ clientUrl: DB_URL, password: DB_PASSWORD, user: DB_USERNAME, - entities: [User, Account, H5PContent, Role, School, System, SchoolYear, InstalledLibrary], + // Needs ALL_ENTITIES for authorization + entities: [...ALL_ENTITIES, H5PContent, TemporaryFile, InstalledLibrary], // debug: true, // use it for locally debugging of querys }), ConfigModule.forRoot(createConfigModuleOptions(config)), + S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), ]; const controllers = [H5PEditorController]; @@ -83,24 +69,6 @@ const providers = [ ContentStorage, LibraryStorage, TemporaryFileStorage, - { - provide: 'S3Config_Content', - useValue: s3ConfigContent, - }, - { - provide: 'S3Config_Libraries', - useValue: s3ConfigLibraries, - }, - { - provide: 'S3ClientAdapter_Content', - useFactory: createS3ClientAdapter, - inject: ['S3Config_Content', LegacyLogger], - }, - { - provide: 'S3ClientAdapter_Libraries', - useFactory: createS3ClientAdapter, - inject: ['S3Config_Libraries', LegacyLogger], - }, ]; @Module({ diff --git a/apps/server/src/modules/h5p-editor/interface/config.ts b/apps/server/src/modules/h5p-editor/interface/config.ts deleted file mode 100644 index 93577407903..00000000000 --- a/apps/server/src/modules/h5p-editor/interface/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface S3Config { - endpoint: string; - region: string; - bucket: string; - accessKeyId: string; - secretAccessKey: string; -} 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..2b1e23f2a13 --- /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'; +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/repo/h5p-content.repo.ts b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts index 9cf6b7fd88e..6713aad5d3a 100644 --- a/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts +++ b/apps/server/src/modules/h5p-editor/repo/h5p-content.repo.ts @@ -9,6 +9,12 @@ export class H5PContentRepo extends BaseRepo { 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); } diff --git a/apps/server/src/modules/h5p-editor/repo/library.repo.ts b/apps/server/src/modules/h5p-editor/repo/library.repo.ts index 3a29c7c6c2d..01aa6eddc4d 100644 --- a/apps/server/src/modules/h5p-editor/repo/library.repo.ts +++ b/apps/server/src/modules/h5p-editor/repo/library.repo.ts @@ -9,7 +9,8 @@ export class LibraryRepo extends BaseRepo { } async createLibrary(library: InstalledLibrary): Promise { - return this.save(this.create(library)); + const entity = this.create(library); + await this.save(entity); } async getAll(): Promise { 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 index ebbd2632596..e643d06bdcd 100644 --- a/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts @@ -8,9 +8,9 @@ import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.ada import { IGetFileResponse } from '@src/modules/files-storage/interface'; import { ObjectID } from 'bson'; import { Readable } from 'stream'; -import { ContentStorage } from './contentStorage.service'; import { H5PContent } from '../entity'; import { H5PContentRepo } from '../repo'; +import { ContentStorage } from './contentStorage.service'; const helpers = { buildMetadata( @@ -321,6 +321,8 @@ describe('ContentStorage', () => { 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, @@ -341,7 +343,6 @@ describe('ContentStorage', () => { it('should call S3ClientAdapter.deleteFile for every file', async () => { const { content, user, files } = setup(); - s3ClientAdapter.list.mockResolvedValueOnce(files); await service.deleteContent(content.id, user); @@ -761,7 +762,8 @@ describe('ContentStorage', () => { it('should return list of filenames', async () => { const { filenames, content, user } = setup(); contentRepo.findById.mockResolvedValueOnce(content); - s3ClientAdapter.list.mockResolvedValueOnce(filenames); + // @ts-expect-error test case + s3ClientAdapter.list.mockResolvedValueOnce({ files: filenames }); const files = await service.listFiles(content.id, user); diff --git a/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts index d8bb96b4daa..adb2443638c 100644 --- a/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts @@ -5,31 +5,43 @@ import { IContentStorage, IFileStats, ILibraryName, - IUser, + IUser as ILumiUser, LibraryName, Permission, } from '@lumieducation/h5p-server'; import { Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { FileDto } from '@src/modules/files-storage/dto'; import { Readable } from 'stream'; -import { S3ClientAdapter } from '../../files-storage/client/s3-client.adapter'; 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('S3ClientAdapter_Content') private readonly storageClient: S3ClientAdapter + @Inject(H5P_CONTENT_S3_CONNECTION) private readonly storageClient: S3ClientAdapter ) {} + private checkExtendedUserType(user: ILumiUser) { + const isExtendedUserType = user instanceof LumiUserWithContentData; + + if (!isExtendedUserType) { + throw new Error('Method expected LumiUserWithContentData instead of IUser'); + } + } + public async addContent( metadata: IContentMetadata, content: unknown, - user: IUser, + user: LumiUserWithContentData, contentId?: ContentId | undefined ): Promise { try { + this.checkExtendedUserType(user); + let h5pContent: H5PContent; if (contentId) { @@ -37,7 +49,14 @@ export class ContentStorage implements IContentStorage { h5pContent.metadata = metadata; h5pContent.content = content; } else { - h5pContent = new H5PContent({ metadata, content }); + h5pContent = new H5PContent({ + parentType: user.contentParentType, + parentId: user.contentParentId, + creatorId: user.id, + schoolId: user.schoolId, + metadata, + content, + }); } await this.repo.save(h5pContent); @@ -48,10 +67,11 @@ export class ContentStorage implements IContentStorage { } } - public async addFile(contentId: string, filename: string, stream: Readable, user?: IUser): Promise { + public async addFile(contentId: string, filename: string, stream: Readable, user?: ILumiUser): Promise { this.checkFilename(filename); - if (!(await this.contentExists(contentId))) { + const contentExists = await this.contentExists(contentId); + if (contentExists) { throw new NotFoundException('The content does not exist'); } @@ -66,15 +86,12 @@ export class ContentStorage implements IContentStorage { } public async contentExists(contentId: string): Promise { - try { - await this.repo.findById(contentId); - return true; - } catch (error) { - return false; - } + const exists = await this.repo.existsOne(contentId); + + return exists; } - public async deleteContent(contentId: string, user?: IUser): Promise { + public async deleteContent(contentId: string, user?: ILumiUser): Promise { try { const h5pContent = await this.repo.findById(contentId); @@ -87,7 +104,7 @@ export class ContentStorage implements IContentStorage { } } - public async deleteFile(contentId: string, filename: string, user?: IUser | undefined): Promise { + public async deleteFile(contentId: string, filename: string, user?: ILumiUser | undefined): Promise { this.checkFilename(filename); const filePath = this.getFilePath(contentId, filename); await this.storageClient.delete([filePath]); @@ -101,7 +118,7 @@ export class ContentStorage implements IContentStorage { return this.exists(filePath); } - public async getFileStats(contentId: string, file: string, user: IUser): Promise { + public async getFileStats(contentId: string, file: string, user: ILumiUser): Promise { const filePath = this.getFilePath(contentId, file); const { ContentLength, LastModified } = await this.storageClient.head(filePath); @@ -123,7 +140,7 @@ export class ContentStorage implements IContentStorage { public async getFileStream( contentId: string, file: string, - user: IUser, + user: ILumiUser, rangeStart = 0, rangeEnd?: number ): Promise { @@ -142,18 +159,18 @@ export class ContentStorage implements IContentStorage { return fileResponse.data; } - public async getMetadata(contentId: string, user?: IUser | undefined): Promise { + public async getMetadata(contentId: string, user?: ILumiUser | undefined): Promise { const h5pContent = await this.repo.findById(contentId); return h5pContent.metadata; } - public async getParameters(contentId: string, user?: IUser | undefined): Promise { + public async getParameters(contentId: string, user?: ILumiUser | undefined): Promise { const h5pContent = await this.repo.findById(contentId); return h5pContent.content; } public async getUsage(library: ILibraryName): Promise<{ asDependency: number; asMainLibrary: number }> { - const defaultUser: IUser = { + const defaultUser: ILumiUser = { canCreateRestricted: false, canInstallRecommended: false, canUpdateAndInstallLibraries: false, @@ -168,26 +185,28 @@ export class ContentStorage implements IContentStorage { return result; } - public getUserPermissions(contentId: string, user: IUser): Promise { + public getUserPermissions(contentId: string, user: ILumiUser): Promise { const permissions = [Permission.Delete, Permission.Download, Permission.Edit, Permission.Embed, Permission.View]; return Promise.resolve(permissions); } - public async listContent(user?: IUser): Promise { + public async listContent(user?: ILumiUser): Promise { const contentList = await this.repo.getAllContents(); const contentIDs = contentList.map((c) => c.id); return contentIDs; } - public async listFiles(contentId: string, user?: IUser): Promise { - if (!(await this.contentExists(contentId))) { + public async listFiles(contentId: string, user?: ILumiUser): Promise { + const contentExists = await this.contentExists(contentId); + if (contentExists) { throw new NotFoundException('Content could not be found'); } - const prefix = this.getContentPath(contentId); - const files = await this.storageClient.list(prefix); + const path = this.getContentPath(contentId); + const { files } = await this.storageClient.list({ path }); + return files; } @@ -223,7 +242,7 @@ export class ContentStorage implements IContentStorage { return false; } - private async resolveDependecies(contentIds: string[], user: IUser, library: ILibraryName) { + private async resolveDependecies(contentIds: string[], user: ILumiUser, library: ILibraryName) { let asDependency = 0; let asMainLibrary = 0; 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 index c22a0f20085..10e3d85c2d3 100644 --- a/apps/server/src/modules/h5p-editor/service/libraryStorage.service.spec.ts +++ b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.spec.ts @@ -1,17 +1,17 @@ import { Readable } from 'stream'; import { HeadObjectCommandOutput, ServiceOutputTypes } from '@aws-sdk/client-s3'; -import { Test, TestingModule } from '@nestjs/testing'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { ILibraryMetadata, ILibraryName } from '@lumieducation/h5p-server'; import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; import { IGetFileResponse } from '@src/modules/files-storage/interface'; +import { FileMetadata, InstalledLibrary } from '../entity/library.entity'; import { LibraryRepo } from '../repo/library.repo'; import { LibraryStorage } from './libraryStorage.service'; -import { FileMetadata, InstalledLibrary } from '../entity/library.entity'; async function readStream(stream: Readable): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -380,6 +380,9 @@ describe('LibraryStorage', () => { 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(); @@ -511,7 +514,8 @@ describe('LibraryStorage', () => { it('should list all files', async () => { const { testingLib, testFile } = await setup(); - s3ClientAdapter.list.mockResolvedValueOnce([testFile.name]); + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: [testFile.name] }); const files = await storage.listFiles(testingLib); expect(files).toContainEqual(expect.stringContaining(testFile.name)); @@ -537,7 +541,8 @@ describe('LibraryStorage', () => { it('should remove all files', async () => { const { testingLib, testFile } = await setup(); - s3ClientAdapter.list.mockResolvedValue([testFile.name]); + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: [testFile.name] }); await storage.clearFiles(testingLib); @@ -610,7 +615,7 @@ describe('LibraryStorage', () => { await expect(getStats).rejects.toThrowError('illegal-filename'); }); - it('should throw NotFoundException if length or birthtime are undefined', async () => { + it('should throw NotFoundException if the file has no content-length or birthtime', async () => { const { testingLib, testFile } = await setup(); s3ClientAdapter.head @@ -642,8 +647,8 @@ describe('LibraryStorage', () => { const languageFiles = ['en.json', 'de.json']; const languages = ['en', 'de']; - - s3ClientAdapter.list.mockResolvedValueOnce(languageFiles); + // @ts-expect-error test + s3ClientAdapter.list.mockResolvedValueOnce({ files: languageFiles }); return { testingLib, 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 index 8b9b78ebd68..71b64ae96b0 100644 --- a/apps/server/src/modules/h5p-editor/service/libraryStorage.service.ts +++ b/apps/server/src/modules/h5p-editor/service/libraryStorage.service.ts @@ -10,12 +10,13 @@ import { type ILibraryStorage, } from '@lumieducation/h5p-server'; import { Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { FileDto } from '@src/modules/files-storage/dto'; import mime from 'mime'; import path from 'node:path/posix'; import { Readable } from 'stream'; import { InstalledLibrary } from '../entity/library.entity'; +import { H5P_LIBRARIES_S3_CONNECTION } from '../h5p-editor.config'; import { LibraryRepo } from '../repo/library.repo'; @Injectable() @@ -25,7 +26,7 @@ export class LibraryStorage implements ILibraryStorage { */ constructor( private readonly libraryRepo: LibraryRepo, - @Inject('S3ClientAdapter_Libraries') private readonly s3Client: S3ClientAdapter + @Inject(H5P_LIBRARIES_S3_CONNECTION) private readonly s3Client: S3ClientAdapter ) {} /** @@ -34,17 +35,19 @@ export class LibraryStorage implements ILibraryStorage { * @param filename the requested file */ private checkFilename(filename: string): void { - if (/\.\.\//.test(filename)) { - throw new H5pError('illegal-filename', { filename }, 400); - } - if (filename.startsWith('/')) { + 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); - return `h5p-libraries/${uberName}/${filename}`; + const s3Key = `h5p-libraries/${uberName}/${filename}`; + + return s3Key; } /** @@ -55,7 +58,7 @@ export class LibraryStorage implements ILibraryStorage { * @returns true if successful */ public async addFile(libraryName: ILibraryName, filename: string, dataStream: Readable): Promise { - this.checkFilename(filename); // TODO: do this everywhere? + this.checkFilename(filename); const s3Key = this.getS3Key(libraryName, filename); @@ -97,34 +100,7 @@ export class LibraryStorage implements ILibraryStorage { throw new Error("Can't add library because it already exists"); } - const library = new InstalledLibrary( - libMeta.machineName, - libMeta.majorVersion, - libMeta.minorVersion, - libMeta.patchVersion, - restricted, - libMeta.runnable, - libMeta.title, - undefined, - libMeta.addTo, - libMeta.author, - libMeta.coreApi, - libMeta.description, - libMeta.dropLibraryCss, - libMeta.dynamicDependencies, - libMeta.editorDependencies, - libMeta.embedTypes, - libMeta.fullscreen, - libMeta.h, - libMeta.license, - libMeta.metadataSettings, - libMeta.preloadedCss, - libMeta.preloadedDependencies, - libMeta.preloadedJs, - libMeta.w, - libMeta.requiredExtensions, - libMeta.state - ); + const library = new InstalledLibrary(libMeta, restricted, undefined); await this.libraryRepo.createLibrary(library); @@ -136,7 +112,9 @@ export class LibraryStorage implements ILibraryStorage { * @param library */ public async clearFiles(libraryName: ILibraryName): Promise { - if (!(await this.isInstalled(libraryName))) { + const isInstalled = await this.isInstalled(libraryName); + + if (!isInstalled) { throw new H5pError('mongo-s3-library-storage:clear-library-not-found', { ubername: LibraryName.toUberName(libraryName), }); @@ -152,7 +130,9 @@ export class LibraryStorage implements ILibraryStorage { * @param library */ public async deleteLibrary(libraryName: ILibraryName): Promise { - if (!(await this.isInstalled(libraryName))) { + const isInstalled = await this.isInstalled(libraryName); + + if (!isInstalled) { throw new H5pError('mongo-s3-library-storage:library-not-found'); } @@ -264,7 +244,8 @@ export class LibraryStorage implements ILibraryStorage { public async getFileStats(libraryName: ILibraryName, file: string): Promise { this.checkFilename(file); - const head = await this.s3Client.head(this.getS3Key(libraryName, file)); + const s3Key = this.getS3Key(libraryName, file); + const head = await this.s3Client.head(s3Key); if (head.LastModified === undefined || head.ContentLength === undefined) { throw new NotFoundException(); @@ -307,9 +288,12 @@ export class LibraryStorage implements ILibraryStorage { public async getLanguages(libraryName: ILibraryName): Promise { const prefix = this.getS3Key(libraryName, 'language'); - const files = await this.s3Client.list(prefix); + const { files } = await this.s3Client.list({ path: prefix }); - return files.filter((file) => path.extname(file) === '.json').map((file) => path.basename(file, '.json')); + const jsonFiles = files.filter((file) => path.extname(file) === '.json'); + const languages = jsonFiles.map((file) => path.basename(file, '.json')); + + return languages; } /** @@ -355,7 +339,9 @@ export class LibraryStorage implements ILibraryStorage { * @returns an array of filenames */ public async listFiles(libraryName: ILibraryName, withMetadata = true): Promise { - const files = await this.s3Client.list(this.getS3Key(libraryName, '')); + const prefix = this.getS3Key(libraryName, 'language'); + + const { files } = await this.s3Client.list({ path: prefix }); if (withMetadata) { return files.concat('library.json'); @@ -444,13 +430,14 @@ export class LibraryStorage implements ILibraryStorage { this.checkFilename(file); if (file === 'library.json') { - const metadata = JSON.stringify(await this.getMetadata(libraryName)); - const readable = Readable.from(metadata); + const metadata = await this.getMetadata(libraryName); + const stringifiedMetadata = JSON.stringify(metadata); + const readable = Readable.from(stringifiedMetadata); return { stream: readable, mimetype: 'application/json', - size: metadata.length, + size: stringifiedMetadata.length, }; } 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 index 22aecf7a130..f5779b73ea4 100644 --- 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 @@ -1,16 +1,16 @@ -import { ReadStream } from 'fs'; -import { join } from 'node:path'; -import { Readable } from 'node:stream'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; 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 { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; -import { IGetFileResponse } from '@src/modules/files-storage/interface'; import { FileDto } from '@src/modules/files-storage/dto'; -import { TemporaryFileStorage } from './temporary-file-storage.service'; +import { IGetFileResponse } from '@src/modules/files-storage/interface'; +import { ReadStream } from 'fs'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { TemporaryFile } from '../entity/temporary-file.entity'; 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); @@ -188,6 +188,7 @@ describe('TemporaryFileStorage', () => { contentLength: undefined, contentRange: undefined, etag: undefined, + name: '', }; repo.findByUserAndFilename.mockResolvedValueOnce(file1); s3clientAdapter.get.mockResolvedValueOnce(response); 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 index 323cfd06733..0fcb5bd8ee8 100644 --- 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 @@ -1,17 +1,18 @@ import { ITemporaryFile, ITemporaryFileStorage, IUser } from '@lumieducation/h5p-server'; import { Inject, Injectable } from '@nestjs/common'; -import { S3ClientAdapter } from '@src/modules/files-storage/client/s3-client.adapter'; +import { S3ClientAdapter } from '@shared/infra/s3-client'; import { FileDto } from '@src/modules/files-storage/dto/file.dto'; import { ReadStream } from 'fs'; import { Readable } from 'stream'; import { TemporaryFile } from '../entity/temporary-file.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('S3ClientAdapter_Content') private readonly s3Client: S3ClientAdapter + @Inject(H5P_CONTENT_S3_CONNECTION) private readonly s3Client: S3ClientAdapter ) {} private checkFilename(filename: string): void { @@ -67,10 +68,18 @@ export class TemporaryFileStorage implements ITemporaryFileStorage { return response.data; } - public async listFiles(user?: IUser | undefined): Promise { + 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 - return user ? this.repo.findByUser(user.id) : this.repo.findExpired(); + + let files: ITemporaryFile[]; + if (user) { + files = await this.repo.findByUser(user.id); + } else { + files = await this.repo.findExpired(); + } + + return files; } public async saveFile( 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/h5p-ajax.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts index 2e19d7d6989..dadef23f23b 100644 --- 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 @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LanguageType, UserDO } from '@shared/domain'; import { setupEntities } from '@shared/testing'; import { UserService } from '@src/modules'; +import { LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; describe('H5P Ajax', () => { @@ -25,6 +26,10 @@ describe('H5P Ajax', () => { provide: H5PPlayer, useValue: createMock(), }, + { + provide: LibraryStorage, + useValue: createMock(), + }, { provide: H5PAjaxEndpoint, useValue: createMock(), @@ -157,22 +162,20 @@ describe('H5P Ajax', () => { 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, - ] + { + 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 = { 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 index 3b3e26d5242..c4e63cef8c0 100644 --- 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 @@ -1,37 +1,33 @@ 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 { setupEntities } from '@shared/testing'; -import { UserService } from '@src/modules'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService, UserService } from '@src/modules'; import { ICurrentUser } from '@src/modules/authentication'; -import { H5PAjaxEndpointService } from '../service'; +import { H5PContentRepo } from '../repo'; +import { H5PAjaxEndpointService, LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; -const setup = () => { - const contentId = '123456789'; - const notExistingContentId = '999999999'; - const currentUser: ICurrentUser = { - userId: '123', - roles: [], - schoolId: '', - accountId: '', - }; - const error = new Error('Could not delete H5P content'); - const errorThrown = new Error('Error: Could not delete H5P content'); - - return { - contentId, - notExistingContentId, - currentUser, - error, - errorThrown, +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', }; + + return { content, mockCurrentUser }; }; describe('save or create H5P content', () => { let module: TestingModule; let uc: H5PEditorUc; let h5pEditor: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -46,15 +42,29 @@ describe('save or create H5P content', () => { provide: H5PPlayer, useValue: createMock(), }, + { + provide: LibraryStorage, + useValue: createMock(), + }, { provide: UserService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); + h5pContentRepo = module.get(H5PContentRepo); + authorizationService = module.get(AuthorizationService); await setupEntities(); }); @@ -66,22 +76,110 @@ describe('save or create H5P content', () => { await module.close(); }); - describe('when contentId is given', () => { - it('should render h5p editor', async () => { - const { contentId, currentUser } = setup(); - // h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValue(); - const result = await uc.deleteH5pContent(currentUser, contentId); + 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(); + authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); - expect(result).toEqual(true); + return { content, mockCurrentUser }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.deleteH5pContent(mockCurrentUser, content.id); + + expect(authorizationService.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 contentId does not exist', () => { - it('should throw an error ', async () => { - const { notExistingContentId, currentUser, error, errorThrown } = setup(); - h5pEditor.deleteContent.mockRejectedValueOnce(error); + 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); + authorizationService.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); + authorizationService.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(uc.deleteH5pContent(currentUser, notExistingContentId)).rejects.toThrowError(errorThrown); + 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 index c357e871afa..0431653e5a9 100644 --- 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 @@ -1,21 +1,49 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { ContentMetadata } from '@lumieducation/h5p-server/build/src/ContentMetadata'; +import { H5PAjaxEndpoint, H5PEditor, IPlayerModel } from '@lumieducation/h5p-server'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { setupEntities } from '@shared/testing'; -import { UserService } from '@src/modules'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService, ICurrentUser, UserService } from '@src/modules'; import { Request } from 'express'; import { Readable } from 'stream'; -import { TemporaryFile } from '../entity/temporary-file.entity'; -import { ContentStorage, H5PAjaxEndpointService, H5PEditorService, H5PPlayerService, LibraryStorage } from '../service'; +import { H5PContentRepo } from '../repo'; +import { ContentStorage, H5PEditorService, H5PPlayerService, LibraryStorage } from '../service'; 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', + }; + + 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 contentStorage: DeepMocked; let libraryStorage: DeepMocked; - let temporaryStorage: DeepMocked; + let ajaxEndpointService: DeepMocked; + let h5pContentRepo: DeepMocked; + let authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -23,7 +51,10 @@ describe('H5P Files', () => { H5PEditorUc, H5PEditorService, H5PPlayerService, - H5PAjaxEndpointService, + { + provide: H5PAjaxEndpoint, + useValue: createMock(), + }, { provide: ContentStorage, useValue: createMock(), @@ -40,13 +71,22 @@ describe('H5P Files', () => { provide: UserService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(H5PEditorUc); - contentStorage = module.get(ContentStorage); libraryStorage = module.get(LibraryStorage); - temporaryStorage = module.get(TemporaryFileStorage); + ajaxEndpointService = module.get(H5PAjaxEndpoint); + h5pContentRepo = module.get(H5PContentRepo); + authorizationService = module.get(AuthorizationService); await setupEntities(); }); @@ -58,290 +98,491 @@ describe('H5P Files', () => { await module.close(); }); - describe('when getting content parameters', () => { - const userMock = { userId: 'dummyId', roles: [], schoolId: 'dummySchool', accountId: 'dummyAccountId' }; + 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); + authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser, mockContentParameters }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getContentParameters(content.id, mockCurrentUser); + + expect(authorizationService.checkPermissionByReferences).toBeCalledWith( + mockCurrentUser.userId, + content.parentType, + content.parentId, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should call service with correct params', async () => { + const { content, mockCurrentUser } = setup(); - it('should call ContentStorage and return the result', async () => { - const dummyMetadata = new ContentMetadata(); - const dummyParams = { name: 'Dummy' }; + await uc.getContentParameters(content.id, mockCurrentUser); + + expect(ajaxEndpointService.getContentParameters).toHaveBeenCalledWith( + content.id, + expect.objectContaining({ + id: mockCurrentUser.userId, + }) + ); + }); - contentStorage.getMetadata.mockResolvedValueOnce(dummyMetadata); - contentStorage.getParameters.mockResolvedValueOnce(dummyParams); + it('should return results of service', async () => { + const { mockCurrentUser, content, mockContentParameters } = setup(); - const result = await uc.getContentParameters('dummylib-1.0', userMock); + const result = await uc.getContentParameters(content.id, mockCurrentUser); - expect(result).toEqual({ - h5p: dummyMetadata, - params: { metadata: dummyMetadata, params: dummyParams }, + expect(result).toEqual(mockContentParameters); }); }); - it('should throw an error if the content does not exist', async () => { - contentStorage.getMetadata.mockRejectedValueOnce(new Error('Could not get Metadata')); - contentStorage.getParameters.mockRejectedValueOnce(new Error('Could not get Parameters')); + describe('WHEN content does not exist', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); - const result = uc.getContentParameters('dummylib-1.0', userMock); + h5pContentRepo.findById.mockRejectedValueOnce(new NotFoundException()); - await expect(result).rejects.toThrow(); + 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 getting content file', () => { - const setup = ( - contentId: string, - filename: string, - content: string, - rangeCallbackReturnValue?: { start: number; end: number }[] | -1 | -2 - ) => { - const fileDate = new Date(); - - const readableContent = Readable.from(content); - const contentLength = content.length; - - let contentRange: { start: number; end: number } | undefined; - if (rangeCallbackReturnValue && rangeCallbackReturnValue !== -1 && rangeCallbackReturnValue !== -2) { - contentRange = rangeCallbackReturnValue[0]; - } - - contentStorage.getFileStats.mockResolvedValueOnce({ birthtime: fileDate, size: contentLength }); - contentStorage.getFileStream.mockResolvedValueOnce(readableContent); - - const requestMock = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - range: (size: number) => rangeCallbackReturnValue, - } as Request; - - const userMock = { userId: 'dummyId', roles: [], schoolId: 'dummySchool', accountId: 'dummyAccountId' }; - - return { contentId, filename, requestMock, contentRange, contentLength, readableContent, userMock }; - }; - - it('should call ContentStorage and return the result', async () => { - const { contentId, filename, requestMock, contentLength, contentRange, readableContent, userMock } = setup( - 'DummyId', - 'dummy-file.jpg', - 'File Content' - ); - - const result = await uc.getContentFile(contentId, filename, requestMock, userMock); - - expect(result).toStrictEqual({ - data: readableContent, - contentType: 'image/jpeg', - contentLength, - contentRange, + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationService.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(); - expect(contentStorage.getFileStats).toHaveBeenCalledWith( - contentId, - filename, - expect.objectContaining({ id: 'dummyId' }) - ); - expect(contentStorage.getFileStream).toHaveBeenCalledWith( - contentId, - filename, - expect.objectContaining({ id: 'dummyId' }), - contentRange?.start, - contentRange?.end - ); + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationService.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); + }); - it('should accept ranges', async () => { - const content = 'File Content'; + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); - const { contentId, filename, requestMock, contentLength, contentRange, readableContent, userMock } = setup( - 'DummyId', - 'dummy-file.jpg', - content, - [{ start: 0, end: content.length }] - ); + const filename = 'test/file.txt'; - const result = await uc.getContentFile(contentId, filename, requestMock, userMock); + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; - expect(result).toStrictEqual({ - data: readableContent, - contentType: 'image/jpeg', - contentLength, - contentRange, + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, filename, requestMock, mockCurrentUser } = setup(); + + await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); + + expect(authorizationService.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) + ); }); - expect(contentStorage.getFileStats).toHaveBeenCalledWith( - contentId, - filename, - expect.objectContaining({ id: 'dummyId' }) - ); - expect(contentStorage.getFileStream).toHaveBeenCalledWith( - contentId, - filename, - expect.objectContaining({ id: 'dummyId' }), - contentRange?.start, - contentRange?.end - ); + 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, + }); + }); }); - it('should fail on invalid ranges', async () => { - const { contentId, filename, requestMock, userMock } = setup('DummyId', 'dummy-file.jpg', 'File Content', -2); - const result = uc.getContentFile(contentId, filename, requestMock, userMock); - await expect(result).rejects.toThrow(); + 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(), + }); + }); + authorizationService.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); + }); }); - it('should fail on unsatisfiable ranges', async () => { - const { contentId, filename, requestMock, userMock } = setup('DummyId', 'dummy-file.jpg', 'File Content', -1); - const result = uc.getContentFile(contentId, filename, requestMock, userMock); - await expect(result).rejects.toThrow(); + 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(); + }); + authorizationService.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); + }); }); - it('should fail on multipart ranges', async () => { - const { contentId, filename, requestMock, userMock } = setup('DummyId', 'dummy-file.jpg', 'File Content', [ - { start: 0, end: 5 }, - { start: 8, end: 12 }, - ]); - const result = uc.getContentFile(contentId, filename, requestMock, userMock); - await expect(result).rejects.toThrow(); + 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); + authorizationService.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('when getting library file', () => { - const setup = (ubername: string, filename: string, mimetype: string, content: string) => { - const readableContent = Readable.from(content); - const contentLength = content.length; + describe('getLibraryFile is called', () => { + describe('WHEN service executes successfully', () => { + const setup = () => { + const fileResponseMock = createMock>>(); - libraryStorage.getLibraryFile.mockResolvedValueOnce({ - size: contentLength, - mimetype, - stream: readableContent, - }); + libraryStorage.getLibraryFile.mockResolvedValueOnce(fileResponseMock); + + const ubername = 'H5P.Test-1.0'; + const filename = 'test/file.txt'; - return { ubername, filename, contentLength, readableContent }; - }; + return { ubername, filename, fileResponseMock }; + }; - it('should call LibraryStorage and return the result', async () => { - const { ubername, filename, contentLength, readableContent } = setup( - 'H5P.Example-1.0', - 'dummy-file.jpg', - 'image/jpeg', - 'File Content' - ); + it('should call service with correct params', async () => { + const { ubername, filename } = setup(); - const result = await uc.getLibraryFile(ubername, filename); + await uc.getLibraryFile(ubername, filename); - expect(result).toStrictEqual({ - data: readableContent, - contentType: 'image/jpeg', - contentLength, + expect(libraryStorage.getLibraryFile).toHaveBeenCalledWith(ubername, filename); }); - expect(libraryStorage.getLibraryFile).toHaveBeenCalledWith('H5P.Example-1.0', 'dummy-file.jpg'); + 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 getting temporary file', () => { - const setup = ( - filename: string, - content: string, - rangeCallbackReturnValue?: { start: number; end: number }[] | -1 | -2 - ) => { - const fileDate = new Date(); - - const readableContent = Readable.from(content); - const contentLength = content.length; - - let contentRange: { start: number; end: number } | undefined; - if (rangeCallbackReturnValue && rangeCallbackReturnValue !== -1 && rangeCallbackReturnValue !== -2) { - contentRange = rangeCallbackReturnValue[0]; - } - - const tempFile = new TemporaryFile({ - filename, - ownedByUserId: 'dummyId', - expiresAt: fileDate, - birthtime: fileDate, - size: contentLength, + 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(); - temporaryStorage.getFileStats.mockResolvedValueOnce(tempFile); - temporaryStorage.getFileStream.mockResolvedValueOnce(readableContent); + const requestMock = createMock(); + const fileResponseMock = createMock>>(); - const requestMock = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - range: (size: number) => rangeCallbackReturnValue, - } as Request; + ajaxEndpointService.getTemporaryFile.mockResolvedValueOnce(fileResponseMock); - const userMock = { userId: 'dummyId', roles: [], schoolId: 'dummySchool', accountId: 'dummyAccountId' }; + const filename = 'test/file.txt'; - return { filename, requestMock, contentRange, contentLength, readableContent, userMock }; - }; + return { content, filename, fileResponseMock, requestMock, mockCurrentUser, mockContentParameters }; + }; - it('should call ContentStorage and return the result', async () => { - const { filename, requestMock, contentLength, contentRange, readableContent, userMock } = setup( - 'dummy-file.jpg', - 'File Content' - ); + it('should call service with correct params', async () => { + const { mockCurrentUser, filename, requestMock } = setup(); - const result = await uc.getTemporaryFile(filename, requestMock, userMock); + await uc.getTemporaryFile(filename, requestMock, mockCurrentUser); - expect(result).toStrictEqual({ - data: readableContent, - contentType: 'image/jpeg', - contentLength, - contentRange, + expect(ajaxEndpointService.getTemporaryFile).toHaveBeenCalledWith( + filename, + expect.objectContaining({ + id: mockCurrentUser.userId, + }), + expect.any(Function) + ); }); - expect(temporaryStorage.getFileStats).toHaveBeenCalledWith(filename, expect.objectContaining({ id: 'dummyId' })); - expect(temporaryStorage.getFileStream).toHaveBeenCalledWith( - filename, - expect.objectContaining({ id: 'dummyId' }), - contentRange?.start, - contentRange?.end - ); + 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, + }); + }); }); - it('should accept ranges', async () => { - const content = 'File Content'; + 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 }; + }; - const { filename, requestMock, contentLength, contentRange, readableContent, userMock } = setup( - 'dummy-file.jpg', - content, - [{ start: 0, end: content.length }] - ); + describe('WHEN content range is invalid', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock } = setup(-2); - const result = await uc.getTemporaryFile(filename, requestMock, userMock); + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); - expect(result).toStrictEqual({ - data: readableContent, - contentType: 'image/jpeg', - contentLength, - contentRange, + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); }); - expect(temporaryStorage.getFileStats).toHaveBeenCalledWith(filename, expect.objectContaining({ id: 'dummyId' })); - expect(temporaryStorage.getFileStream).toHaveBeenCalledWith( - filename, - expect.objectContaining({ id: 'dummyId' }), - contentRange?.start, - contentRange?.end - ); - }); + describe('WHEN content range is unsatisfiable', () => { + it('should throw NotFoundException', async () => { + const { mockCurrentUser, filename, requestMock } = setup(-1); - it('should fail on invalid ranges', async () => { - const { filename, requestMock, userMock } = setup('dummy-file.jpg', 'File Content', -2); - const result = uc.getTemporaryFile(filename, requestMock, userMock); - await expect(result).rejects.toThrow(); - }); + const getTemporaryFilePromise = uc.getTemporaryFile(filename, requestMock, mockCurrentUser); + + await expect(getTemporaryFilePromise).rejects.toThrow(NotFoundException); + }); + }); - it('should fail on unsatisfiable ranges', async () => { - const { filename, requestMock, userMock } = setup('dummy-file.jpg', 'File Content', -2); - const result = uc.getTemporaryFile(filename, requestMock, userMock); - await expect(result).rejects.toThrow(); + 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); + }); + }); }); - it('should fail on multipart ranges', async () => { - const { filename, requestMock, userMock } = setup('dummy-file.jpg', 'File Content', [ - { start: 0, end: 5 }, - { start: 8, end: 12 }, - ]); - const result = uc.getTemporaryFile(filename, requestMock, userMock); - await expect(result).rejects.toThrow(); + 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 index 59c5971ab58..c480e5e1dc7 100644 --- 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 @@ -1,51 +1,47 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; +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 { setupEntities } from '@shared/testing'; -import { UserService } from '@src/modules'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService, UserService } from '@src/modules'; import { ICurrentUser } from '@src/modules/authentication'; -import { H5PAjaxEndpointService } from '../service'; +import { H5PContentRepo } from '../repo'; +import { H5PAjaxEndpointService, LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; -const setup = () => { - const contentId = '123456789'; - const contentIdCreate = 'create'; - const currentUser: ICurrentUser = { - userId: '123', - roles: [], - schoolId: '', - accountId: '', - }; - - const language = 'de'; - - const playerModel = { - contentId, - dependencies: [{ machineName: 'ExampleLibrary', majorVersion: 1, minorVersion: 2 }], - }; +const createParams = () => { + const content = h5pContentFactory.build(); - const editorModel = { - scripts: ['example.js'], - styles: ['example.css'], + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', }; - const exampleContent = { - h5p: {}, - library: 'ExampleLib-1.0', + const editorResponseMock = { scripts: ['test.js'] } as IEditorModel; + const contentResponseMock: Awaited> = { + h5p: content.metadata, + library: content.metadata.mainLibrary, params: { - metadata: {}, - params: { anything: true }, + metadata: content.metadata, + params: content.content, }, }; - return { contentId, contentIdCreate, currentUser, playerModel, editorModel, exampleContent, language }; + 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 authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -64,15 +60,29 @@ describe('get H5P editor', () => { provide: UserRepo, useValue: createMock(), }, + { + provide: LibraryStorage, + useValue: createMock(), + }, { provide: UserService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); + h5pContentRepo = module.get(H5PContentRepo); + authorizationService = module.get(AuthorizationService); await setupEntities(); }); @@ -84,35 +94,182 @@ describe('get H5P editor', () => { await module.close(); }); - describe('when value of contentId is create', () => { - it('should render new h5p editor', async () => { - const { language, currentUser, editorModel } = setup(); - h5pEditor.render.mockResolvedValueOnce(editorModel); - const result = await uc.getEmptyH5pEditor(currentUser, language); + 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(result).toEqual(editorModel); + 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 contentId is given', () => { - it('should render h5p editor', async () => { - const { contentId, language, currentUser, editorModel, exampleContent } = setup(); - h5pEditor.render.mockResolvedValueOnce(editorModel); - // @ts-expect-error partial object - h5pEditor.getContent.mockResolvedValueOnce(exampleContent); - const result = await uc.getH5pEditor(currentUser, contentId, language); + 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 }; + }; - expect(result).toEqual({ editorModel, content: exampleContent }); + 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('when contentId does not exist', () => { - it('should throw an error ', async () => { - const { contentId, currentUser, language } = setup(); - h5pEditor.render.mockRejectedValueOnce(new Error('Could not get H5P editor')); - const result = uc.getH5pEditor(currentUser, contentId, language); + 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); + authorizationService.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(authorizationService.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); + authorizationService.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); + authorizationService.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(result).rejects.toThrow(); + 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 index a876ba12ea6..c0872056c7a 100644 --- 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 @@ -1,30 +1,37 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; +import { H5PEditor, H5PPlayer, IPlayerModel } from '@lumieducation/h5p-server'; import { Test, TestingModule } from '@nestjs/testing'; -import { setupEntities } from '@shared/testing'; -import { UserService } from '@src/modules'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService, UserService } from '@src/modules'; import { ICurrentUser } from '@src/modules/authentication'; -import { H5PAjaxEndpointService } from '../service'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { H5PContentRepo } from '../repo'; +import { H5PAjaxEndpointService, LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; -const setup = () => { - const contentId = '123456789'; - const notExistingContentId = '0000'; - const currentUser: ICurrentUser = { - userId: '123', - roles: [], - schoolId: '', - accountId: '', +const createParams = () => { + const content = h5pContentFactory.build(); + + const mockCurrentUser: ICurrentUser = { + accountId: 'mockAccountId', + roles: ['student'], + schoolId: 'mockSchoolId', + userId: 'mockUserId', }; - const htmlString = 'htmlString'; - return { contentId, notExistingContentId, currentUser, htmlString }; + 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 authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -39,15 +46,29 @@ describe('get H5P player', () => { provide: H5PPlayer, useValue: createMock(), }, + { + provide: LibraryStorage, + useValue: createMock(), + }, { provide: UserService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(H5PEditorUc); h5pPlayer = module.get(H5PPlayer); + h5pContentRepo = module.get(H5PContentRepo); + authorizationService = module.get(AuthorizationService); await setupEntities(); }); @@ -59,23 +80,116 @@ describe('get H5P player', () => { await module.close(); }); - describe('when contentId is given', () => { - it('should render h5p player', async () => { - const { contentId, currentUser, htmlString } = setup(); - h5pPlayer.render.mockResolvedValueOnce(htmlString); - const result = await uc.getH5pPlayer(currentUser, contentId); + 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); + authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + + return { content, mockCurrentUser, expectedResult }; + }; + + it('should call authorizationService.checkPermissionByReferences', async () => { + const { content, mockCurrentUser } = setup(); + + await uc.getH5pPlayer(mockCurrentUser, content.id); + + expect(authorizationService.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); - expect(result).toEqual('htmlString'); + await expect(getPlayerPromise).rejects.toThrow(new NotFoundException()); + + expect(h5pPlayer.render).toHaveBeenCalledTimes(0); }); }); - describe('when contentId does not exist', () => { - it('should throw an error', async () => { - const { notExistingContentId, currentUser } = setup(); - h5pPlayer.render.mockRejectedValueOnce(new Error('Could not get H5P player')); - const result = uc.getH5pPlayer(currentUser, notExistingContentId); + describe('WHEN user is not authorized', () => { + const setup = () => { + const { content, mockCurrentUser, playerResponseMock } = createParams(); + + h5pContentRepo.findById.mockResolvedValueOnce(content); + authorizationService.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); + authorizationService.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(result).rejects.toThrow(); + 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 index 737816f3504..d151e9b6d40 100644 --- 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 @@ -1,51 +1,40 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { H5PEditor, H5PPlayer, IContentMetadata } from '@lumieducation/h5p-server'; +import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; import { Test, TestingModule } from '@nestjs/testing'; -import { setupEntities } from '@shared/testing'; -import { UserService } from '@src/modules'; +import { h5pContentFactory, setupEntities } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService, UserService } from '@src/modules'; import { ICurrentUser } from '@src/modules/authentication'; -import { H5PAjaxEndpointService } from '../service'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ForbiddenException } from '@nestjs/common'; +import { H5PAjaxEndpointService, LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; +import { H5PContentParentType } from '../entity'; +import { H5PContentRepo } from '../repo'; +import { LumiUserWithContentData } from '../types/lumi-types'; -const setup = () => { - const contentId = '123456789'; - const notExistingContentId = '999999999'; - const id = '0000000'; - const metadata: IContentMetadata = { - embedTypes: [], - language: 'de', - mainLibrary: 'mainLib', - preloadedDependencies: [], - defaultLanguage: '', - license: '', - title: '123', - }; - const params = {}; - const mainLibraryUbername = 'mainLib'; - const currentUser: ICurrentUser = { - userId: '123', - roles: [], - schoolId: '', - accountId: '', - }; - const error = new Error('Could not save H5P content'); - - return { - contentId, - notExistingContentId, - currentUser, - params, - metadata, - mainLibraryUbername, - id, - error, +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', }; + + return { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser }; }; describe('save or create H5P content', () => { let module: TestingModule; let uc: H5PEditorUc; let h5pEditor: DeepMocked; + let authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -60,15 +49,28 @@ describe('save or create H5P content', () => { provide: H5PPlayer, useValue: createMock(), }, + { + provide: LibraryStorage, + useValue: createMock(), + }, { provide: UserService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: H5PContentRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); + authorizationService = module.get(AuthorizationService); await setupEntities(); }); @@ -80,43 +82,256 @@ describe('save or create H5P content', () => { await module.close(); }); - describe('save H5P content', () => { - describe('when contentId is given', () => { - it('should render h5p editor', async () => { - const { contentId, metadata, mainLibraryUbername, params, currentUser, id } = setup(); - const result1 = { id, metadata }; - h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce(result1); + 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 }); + authorizationService.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(authorizationService.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, - currentUser, - params, + mockCurrentUser, + parameters, metadata, - mainLibraryUbername + mainLibraryUbername, + H5PContentParentType.Lesson, + parentId ); - expect(result).toEqual(result1); + expect(result).toEqual({ id: contentId, metadata }); }); }); - describe('when contentId does not exist', () => { - it('should throw an error ', async () => { - const { metadata, mainLibraryUbername, params, notExistingContentId, currentUser, error } = setup(); + describe('WHEN user is not authorized', () => { + const setup = () => { + const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); + + authorizationService.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'); + + authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); h5pEditor.saveOrUpdateContentReturnMetaData.mockRejectedValueOnce(error); - await expect( - uc.saveH5pContentGetMetadata(notExistingContentId, currentUser, params, metadata, mainLibraryUbername) - ).rejects.toThrowError(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('create H5P content', () => { - it('should create new h5p content', async () => { - const { metadata, mainLibraryUbername, params, currentUser, id } = setup(); - const result1 = { id, metadata }; - h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce(result1); - const result = await uc.createH5pContentGetMetadata(currentUser, params, metadata, mainLibraryUbername); + 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 }); + authorizationService.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(authorizationService.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(); + + authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + + return { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; + }; - expect(result).toEqual(result1); + 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'); + + authorizationService.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 index a89403a607e..9b1ccf4932c 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts @@ -6,7 +6,7 @@ import { IContentMetadata, IEditorModel, IPlayerModel, - IUser, + IUser as LumiIUser, } from '@lumieducation/h5p-server'; import { BadRequestException, @@ -15,13 +15,17 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { LanguageType } from '@shared/domain'; -import { UserService } from '@src/modules'; +import { EntityId, LanguageType } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService, UserService } from '@src/modules'; import { ICurrentUser } from '@src/modules/authentication'; import { Request } from 'express'; import { Readable } from 'stream'; import { AjaxGetQueryParams, AjaxPostBodyParams, AjaxPostQueryParams } from '../controller/dto'; +import { H5PContentParentType } from '../entity'; +import { H5PContentMapper } from '../mapper/h5p-content.mapper'; +import { H5PContentRepo } from '../repo'; import { LibraryStorage } from '../service'; +import { LumiUserWithContentData } from '../types/lumi-types'; @Injectable() export class H5PEditorUc { @@ -30,9 +34,21 @@ export class H5PEditorUc { private h5pPlayer: H5PPlayer, private h5pAjaxEndpoint: H5PAjaxEndpoint, private libraryService: LibraryStorage, - private readonly userService: UserService + private readonly userService: UserService, + private readonly authorizationService: AuthorizationService, + private readonly h5pContentRepo: H5PContentRepo ) {} + private async checkContentPermission( + userId: EntityId, + parentType: H5PContentParentType, + parentId: EntityId, + context: AuthorizationContext + ) { + const allowedType = H5PContentMapper.mapToAllowedAuthorizationEntityType(parentType); + await this.authorizationService.checkPermissionByReferences(userId, allowedType, parentId, context); + } + /** * Returns a callback that parses the request range. */ @@ -61,8 +77,6 @@ export class H5PEditorUc { } private mapH5pError(error: unknown) { - console.error(error); - if (error instanceof H5pError) { return new HttpException(error.message, error.httpStatusCode); } @@ -94,33 +108,31 @@ export class H5PEditorUc { currentUser: ICurrentUser, query: AjaxPostQueryParams, body: AjaxPostBodyParams, - files?: Express.Multer.File[] + contentFile?: Express.Multer.File, + h5pFile?: Express.Multer.File ) { const user = this.changeUserType(currentUser); const language = await this.getUserLanguage(currentUser); try { - const filesFile = files?.find((file) => file.fieldname === 'file'); - const libraryUploadFile = files?.find((file) => file.fieldname === 'h5p'); - const result = await this.h5pAjaxEndpoint.postAjax( query.action, body, language, user, - filesFile && { - data: filesFile.buffer, - mimetype: filesFile.mimetype, - name: filesFile.originalname, - size: filesFile.size, + contentFile && { + data: contentFile.buffer, + mimetype: contentFile.mimetype, + name: contentFile.originalname, + size: contentFile.size, }, query.id, undefined, - libraryUploadFile && { - data: libraryUploadFile.buffer, - mimetype: libraryUploadFile.mimetype, - name: libraryUploadFile.originalname, - size: libraryUploadFile.size, + h5pFile && { + data: h5pFile.buffer, + mimetype: h5pFile.mimetype, + name: h5pFile.originalname, + size: h5pFile.size, }, undefined // TODO: HubID? ); @@ -132,6 +144,9 @@ export class H5PEditorUc { } 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 { @@ -154,14 +169,18 @@ export class H5PEditorUc { contentLength: number; contentRange?: { start: number; end: number }; }> { + 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, - this.getRange(req) + rangeCallback ); return { @@ -202,11 +221,12 @@ export class H5PEditorUc { const user = this.changeUserType(currentUser); try { + const rangeCallback = this.getRange(req); const { mimetype, range, stats, stream } = await this.h5pAjaxEndpoint.getTemporaryFile( file, user, // @ts-expect-error 2345: Callback can return undefined, typings from @lumieducation/h5p-server are wrong - this.getRange(req) + rangeCallback ); return { @@ -221,16 +241,19 @@ export class H5PEditorUc { } public async getH5pPlayer(currentUser: ICurrentUser, contentId: string): Promise { - // TODO: await this.checkPermission... + 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) { - // TODO: await this.checkPermission... const user = this.changeUserType(currentUser); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const createdH5PEditor: IEditorModel = await this.h5pEditor.render( undefined as unknown as string, // Lumi typings are wrong because they dont "use strict", this method actually accepts both string and undefined @@ -242,7 +265,9 @@ export class H5PEditorUc { } public async getH5pEditor(currentUser: ICurrentUser, contentId: string, language: LanguageType) { - // TODO: await this.checkPermission... + 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([ @@ -257,7 +282,9 @@ export class H5PEditorUc { } public async deleteH5pContent(currentUser: ICurrentUser, contentId: string): Promise { - // TODO: await this.checkPermission... + 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 { @@ -275,10 +302,13 @@ export class H5PEditorUc { currentUser: ICurrentUser, params: unknown, metadata: IContentMetadata, - mainLibraryUbername: string + mainLibraryUbername: string, + parentType: H5PContentParentType, + parentId: EntityId ): Promise<{ id: string; metadata: IContentMetadata }> { - // TODO: await this.checkPermission... - const user = this.changeUserType(currentUser); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); const newContentId = await this.h5pEditor.saveOrUpdateContentReturnMetaData( undefined as unknown as string, // Lumi typings are wrong because they dont "use strict", this method actually accepts both string and undefined @@ -296,10 +326,13 @@ export class H5PEditorUc { currentUser: ICurrentUser, params: unknown, metadata: IContentMetadata, - mainLibraryUbername: string + mainLibraryUbername: string, + parentType: H5PContentParentType, + parentId: EntityId ): Promise<{ id: string; metadata: IContentMetadata }> { - // TODO: await this.checkPermission... - const user = this.changeUserType(currentUser); + await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + + const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); const newContentId = await this.h5pEditor.saveOrUpdateContentReturnMetaData( contentId, @@ -312,13 +345,8 @@ export class H5PEditorUc { return newContentId; } - public dummyFunction(): void { - console.log('Dummy command works!'); - } - - private changeUserType(currentUser: ICurrentUser): IUser { - // TODO: declare IUser (e.g. add roles, schoolId, etc.) - const user: IUser = { + private changeUserType(currentUser: ICurrentUser): LumiIUser { + const user: LumiIUser = { canCreateRestricted: false, canInstallRecommended: true, canUpdateAndInstallLibraries: true, @@ -327,13 +355,28 @@ export class H5PEditorUc { 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 language = 'de'; - if (languageUser && languageUser.language) { + if (languageUser?.language) { language = languageUser.language.toString(); } return language; diff --git a/apps/server/src/modules/h5p-library-management/h5p-library-management.config.ts b/apps/server/src/modules/h5p-library-management/h5p-library-management.config.ts new file mode 100644 index 00000000000..5c2448cd3fd --- /dev/null +++ b/apps/server/src/modules/h5p-library-management/h5p-library-management.config.ts @@ -0,0 +1,34 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; +import { S3Config } from '@shared/infra/s3-client'; + +const h5pEditorConfig = { + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, +}; + +export const translatorConfig = { + AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(','), +}; + +export const H5P_CONTENT_S3_CONNECTION = 'H5P_CONTENT_S3_CONNECTION'; +export const H5P_LIBRARIES_S3_CONNECTION = 'H5P_LIBRARIES_S3_CONNECTION'; + +export const s3ConfigContent: S3Config = { + connectionName: H5P_CONTENT_S3_CONNECTION, + endpoint: Configuration.get('H5P_EDITOR__S3_ENDPOINT') as string, + region: Configuration.get('H5P_EDITOR__S3_REGION') as string, + bucket: Configuration.get('H5P_EDITOR__S3_BUCKET_CONTENT') as string, + accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID_RW') as string, + secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY_RW') as string, +}; + +export const s3ConfigLibraries: S3Config = { + connectionName: H5P_LIBRARIES_S3_CONNECTION, + endpoint: Configuration.get('H5P_Library__S3_ENDPOINT') as string, + region: Configuration.get('H5P_EDITOR__S3_REGION') as string, + bucket: Configuration.get('H5P_Library__S3_BUCKET_LIBRARIES') as string, + accessKeyId: Configuration.get('H5P_Library__S3_ACCESS_KEY_ID') as string, + secretAccessKey: Configuration.get('H5P_Library__S3_SECRET_ACCESS_KEY') as string, +}; + +export const config = () => h5pEditorConfig; diff --git a/apps/server/src/modules/h5p-library-management/h5p-library-management.module.ts b/apps/server/src/modules/h5p-library-management/h5p-library-management.module.ts index ad3c5419368..b2236437de0 100644 --- a/apps/server/src/modules/h5p-library-management/h5p-library-management.module.ts +++ b/apps/server/src/modules/h5p-library-management/h5p-library-management.module.ts @@ -5,18 +5,26 @@ import { ConfigModule } from '@nestjs/config'; import { Account, Role, School, SchoolYear, System, User } from '@shared/domain'; import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq'; +import { S3ClientModule } from '@shared/infra/s3-client'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; -import { LegacyLogger, Logger } from '@src/core/logger'; +import { Logger } from '@src/core/logger'; import { UserModule } from '..'; +<<<<<<< HEAD import { ContentStorage, LibraryStorage } from '../h5p-editor/service'; import { config, s3ConfigContent, s3ConfigLibraries } from '../h5p-editor/h5p-editor.config'; import { H5PContentRepo, LibraryRepo } from '../h5p-editor/repo'; import { createS3ClientAdapter } from '../h5p-editor'; +======= +import { config, s3ConfigContent } from '../h5p-editor/h5p-editor.config'; +import { LibraryRepo } from '../h5p-editor/repo'; +import { LibraryStorage } from '../h5p-editor/service'; +import { s3ConfigLibraries } from './h5p-library-management.config'; +>>>>>>> b668fbf5fbd4ddf57ae7fec4ab7779064475e3af -import { H5PLibraryManagementService } from './service/h5p-library-management.service'; import { InstalledLibrary } from '../h5p-editor/entity'; +import { H5PLibraryManagementService } from './service/h5p-library-management.service'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -39,6 +47,7 @@ const imports = [ entities: [User, Account, Role, School, System, SchoolYear, InstalledLibrary], }), ConfigModule.forRoot(createConfigModuleOptions(config)), + S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), ]; const controllers = []; diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index a38a309e9c6..6e0070a7867 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -18,7 +18,6 @@ export * from './school'; export * from './sharing'; export * from './system'; export * from './task'; -export * from './task-card'; export * from './tool'; export * from './user'; export * from './user-import'; diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.spec.ts deleted file mode 100644 index 2aef06de2fa..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - CommonCartridgeAssignmentResourceItemElement, - ICommonCartridgeAssignmentResourceItemProps, -} from './common-cartridge-assignment-resource-item-element'; - -describe('CommonCartridgeAssignmentResourceItemElement', () => { - let props: ICommonCartridgeAssignmentResourceItemProps; - let element: CommonCartridgeAssignmentResourceItemElement; - - beforeEach(() => { - props = { - identifier: 'assignment-identifier', - type: 'assignment', - href: 'https://example.tld', - }; - element = new CommonCartridgeAssignmentResourceItemElement(props); - }); - - it('should transform props into the expected element structure', () => { - const expectedOutput = { - $: { - identifier: props.identifier, - type: props.type, - }, - file: { - $: { - href: props.href, - }, - }, - }; - - const transformed = element.transform(); - - expect(transformed).toEqual(expectedOutput); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.ts deleted file mode 100644 index ba9e5dab019..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-assignment-resource-item-element.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ICommonCartridgeElement } from './common-cartridge-element.interface'; - -export type ICommonCartridgeAssignmentResourceItemProps = { - identifier: string; - type: string; - href: string; -}; - -export class CommonCartridgeAssignmentResourceItemElement implements ICommonCartridgeElement { - constructor(private readonly props: ICommonCartridgeAssignmentResourceItemProps) {} - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - type: this.props.type, - }, - file: { - $: { - href: this.props.href, - }, - }, - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts index 526d636c871..52ccd5c5818 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts @@ -9,3 +9,10 @@ export enum CommonCartridgeResourceType { WEB_LINK_V1 = 'imswl_xmlv1p1', WEB_LINK_V3 = 'imswl_xmlv1p3', } + +export enum CommonCartridgeIntendedUseType { + ASSIGNMENT = 'assignment', + LESSON_PLAN = 'lessonplan', + SYLLABUS = 'syllabus', + UNSPECIFIED = 'unspecified', +} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts index 80e0216ae2c..9c44732c61e 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts @@ -4,7 +4,6 @@ import { CommonCartridgeFileBuilder, ICommonCartridgeFileBuilderOptions } from ' import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; import { ICommonCartridgeOrganizationProps } from './common-cartridge-organization-item-element'; import { ICommonCartridgeResourceProps } from './common-cartridge-resource-item-element'; -import { CommonCartridgeAssignmentResourceItemElement } from './common-cartridge-assignment-resource-item-element'; describe('CommonCartridgeFileBuilder', () => { let archive: AdmZip; @@ -103,19 +102,4 @@ describe('CommonCartridgeFileBuilder', () => { }); }); }); - - describe('some tests for coverage reasons', () => { - // it('throw if resource type is unknown', () => { - // expect(() => new CommonCartridgeResourceItemElement({} as ICommonCartridgeResourceProps, {})).toThrow(); - // }); - - it('should cover CommonCartridgeResourceItemElement', () => { - const element = new CommonCartridgeAssignmentResourceItemElement({ - href: 'href', - identifier: 'identifier', - type: 'type', - }); - expect(() => element.transform()).not.toThrow(); - }); - }); }); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts index 2be83bf4882..e119aba85fe 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts @@ -26,14 +26,15 @@ export interface ICommonCartridgeOrganizationBuilder { export interface ICommonCartridgeFileBuilder { addOrganization(props: ICommonCartridgeOrganizationProps): ICommonCartridgeOrganizationBuilder; + addResourceToFile(props: ICommonCartridgeResourceProps): ICommonCartridgeFileBuilder; + build(): Promise; } class CommonCartridgeOrganizationBuilder implements ICommonCartridgeOrganizationBuilder { constructor( private readonly props: ICommonCartridgeOrganizationProps, - private readonly fileBuilder: ICommonCartridgeFileBuilder, private readonly xmlBuilder: Builder, private readonly zipBuilder: AdmZip ) {} @@ -70,7 +71,7 @@ export class CommonCartridgeFileBuilder implements ICommonCartridgeFileBuilder { constructor(private readonly options: ICommonCartridgeFileBuilderOptions) {} addOrganization(props: ICommonCartridgeOrganizationProps): ICommonCartridgeOrganizationBuilder { - const organizationBuilder = new CommonCartridgeOrganizationBuilder(props, this, this.xmlBuilder, this.zipBuilder); + const organizationBuilder = new CommonCartridgeOrganizationBuilder(props, this.xmlBuilder, this.zipBuilder); this.organizations.push(organizationBuilder); return organizationBuilder; } diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts index a073e52ab56..e77fcbc0905 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts @@ -1,6 +1,6 @@ -import { ObjectId } from 'bson'; import { ICommonCartridgeElement } from './common-cartridge-element.interface'; import { ICommonCartridgeResourceProps } from './common-cartridge-resource-item-element'; +import { createIdentifier } from './utils'; export type ICommonCartridgeOrganizationProps = { identifier: string; @@ -19,10 +19,9 @@ export class CommonCartridgeOrganizationItemElement implements ICommonCartridgeE }, title: this.props.title, item: this.props.resources.map((content) => { - const newId = new ObjectId(); return { $: { - identifier: `i${newId.toString()}`, + identifier: createIdentifier(), identifierref: content.identifier, }, title: content.title, diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts index 4230edd80f1..5bcb07c0b2f 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts @@ -4,7 +4,7 @@ import { CommonCartridgeWebContentResource, } from './common-cartridge-web-content-resource'; -describe('ICommonCartridgeWebContentResource', () => { +describe('CommonCartridgeWebContentResource', () => { const props: ICommonCartridgeWebContentResourceProps = { type: CommonCartridgeResourceType.WEB_CONTENT, version: CommonCartridgeVersion.V_1_3_0, @@ -33,6 +33,7 @@ describe('ICommonCartridgeWebContentResource', () => { $: { identifier: props.identifier, type: props.type, + intendeduse: 'unspecified', }, file: { $: { diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts index 91cbb934c9a..740f4779a17 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts @@ -1,38 +1,44 @@ -import { ICommonCartridgeElement } from './common-cartridge-element.interface'; -import { ICommonCartridgeFile } from './common-cartridge-file.interface'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; - -export type ICommonCartridgeWebContentResourceProps = { - type: CommonCartridgeResourceType.WEB_CONTENT; - version: CommonCartridgeVersion; - identifier: string; - href: string; - title: string; - html: string; -}; - -export class CommonCartridgeWebContentResource implements ICommonCartridgeElement, ICommonCartridgeFile { - constructor(private readonly props: ICommonCartridgeWebContentResourceProps) {} - - canInline(): boolean { - return false; - } - - content(): string { - return this.props.html; - } - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - type: this.props.type, - }, - file: { - $: { - href: this.props.href, - }, - }, - }; - } -} +import { ICommonCartridgeElement } from './common-cartridge-element.interface'; +import { ICommonCartridgeFile } from './common-cartridge-file.interface'; +import { + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from './common-cartridge-enums'; + +export type ICommonCartridgeWebContentResourceProps = { + type: CommonCartridgeResourceType.WEB_CONTENT; + version: CommonCartridgeVersion; + identifier: string; + href: string; + title: string; + html: string; + intendedUse?: CommonCartridgeIntendedUseType; +}; + +export class CommonCartridgeWebContentResource implements ICommonCartridgeElement, ICommonCartridgeFile { + constructor(private readonly props: ICommonCartridgeWebContentResourceProps) {} + + canInline(): boolean { + return false; + } + + content(): string { + return this.props.html; + } + + transform(): Record { + return { + $: { + identifier: this.props.identifier, + type: this.props.type, + intendeduse: this.props.intendedUse ?? CommonCartridgeIntendedUseType.UNSPECIFIED, + }, + file: { + $: { + href: this.props.href, + }, + }, + }; + } +} diff --git a/apps/server/src/modules/learnroom/common-cartridge/index.ts b/apps/server/src/modules/learnroom/common-cartridge/index.ts index 581be3515ba..ed0b36becf1 100644 --- a/apps/server/src/modules/learnroom/common-cartridge/index.ts +++ b/apps/server/src/modules/learnroom/common-cartridge/index.ts @@ -1,15 +1,14 @@ -export * from './common-cartridge-assignment-resource-item-element'; -export * from './common-cartridge-element.interface'; -export * from './common-cartridge-enums'; -export * from './common-cartridge-file-builder'; -export * from './common-cartridge-file.interface'; -export * from './common-cartridge-lti-resource'; -export * from './common-cartridge-manifest-element'; -export * from './common-cartridge-metadata-element'; -export * from './common-cartridge-organization-item-element'; -export * from './common-cartridge-organization-wrapper-element'; -export * from './common-cartridge-resource-item-element'; -export * from './common-cartridge-resource-wrapper-element'; -export * from './common-cartridge-web-content-resource'; -export * from './common-cartridge-web-link-resource'; -export * from './common-cartridge.config'; +export * from './common-cartridge-element.interface'; +export * from './common-cartridge-enums'; +export * from './common-cartridge-file-builder'; +export * from './common-cartridge-file.interface'; +export * from './common-cartridge-lti-resource'; +export * from './common-cartridge-manifest-element'; +export * from './common-cartridge-metadata-element'; +export * from './common-cartridge-organization-item-element'; +export * from './common-cartridge-organization-wrapper-element'; +export * from './common-cartridge-resource-item-element'; +export * from './common-cartridge-resource-wrapper-element'; +export * from './common-cartridge-web-content-resource'; +export * from './common-cartridge-web-link-resource'; +export * from './common-cartridge.config'; diff --git a/apps/server/src/modules/learnroom/common-cartridge/utils.ts b/apps/server/src/modules/learnroom/common-cartridge/utils.ts new file mode 100644 index 00000000000..b83f8ec220a --- /dev/null +++ b/apps/server/src/modules/learnroom/common-cartridge/utils.ts @@ -0,0 +1,6 @@ +import { ObjectId } from 'bson'; + +export function createIdentifier(id?: string | ObjectId): string { + id = id ?? new ObjectId(); + return `i${id.toString()}`; +} diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index c13531ae2e3..0d6518878f7 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -1,9 +1,9 @@ -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { EntityManager } from '@mikro-orm/mongodb'; import { INestApplication, StreamableFile } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; import { cleanupCollections, courseFactory, UserAndAccountTestFactory, TestApiClient } from '@shared/testing'; -import { CourseMetadataListResponse, CourseResponse } from '@src/modules/learnroom/controller/dto'; +import { CourseMetadataListResponse } from '@src/modules/learnroom/controller/dto'; import { ServerTestModule } from '@src/modules/server/server.module'; const createStudent = () => { @@ -81,102 +81,6 @@ describe('Course Controller (API)', () => { }); }); - describe('[GET] /courses/:id', () => { - const setup = () => { - const student1 = createStudent(); - const student2 = createStudent(); - const teacher = createTeacher(); - const substitutionTeacher = createTeacher(); - const teacherUnkownToCourse = createTeacher(); - const course = courseFactory.build({ - name: 'course #1', - teachers: [teacher.user], - substitutionTeachers: [substitutionTeacher.user], - students: [student1.user, student2.user], - }); - const courseWithoutStartAndUntilDate = courseFactory.build({ - name: 'course #2', - teachers: [teacher.user], - substitutionTeachers: [substitutionTeacher.user], - students: [student1.user, student2.user], - startDate: undefined, - untilDate: undefined, - }); - - return { course, teacher, teacherUnkownToCourse, substitutionTeacher, student1, courseWithoutStartAndUntilDate }; - }; - it('should find course as teacher', async () => { - const { course, teacher } = setup(); - await em.persistAndFlush([teacher.user, teacher.account, course]); - - em.clear(); - - const loggedInClient = await testApiClient.login(teacher.account); - const response = await loggedInClient.get(`${course.id}`); - const courseResponse = response.body as CourseResponse; - - expect(response.statusCode).toEqual(200); - expect(courseResponse).toBeDefined(); - expect(courseResponse.id).toEqual(course.id); - expect(courseResponse.students?.length).toEqual(2); - expect(courseResponse.startDate).toEqual(course.startDate); - }); - it('should find course as substitution teacher', async () => { - const { course, substitutionTeacher } = setup(); - await em.persistAndFlush([substitutionTeacher.user, substitutionTeacher.account, course]); - - em.clear(); - - const loggedInClient = await testApiClient.login(substitutionTeacher.account); - const response = await loggedInClient.get(`${course.id}`); - const courseResponse = response.body as CourseResponse; - - expect(response.statusCode).toEqual(200); - expect(courseResponse).toBeDefined(); - expect(courseResponse.id).toEqual(course.id); - expect(courseResponse.students?.length).toEqual(2); - expect(courseResponse.startDate).toEqual(course.startDate); - }); - it('should not find course if the teacher is not assigned to', async () => { - const { teacherUnkownToCourse, course } = setup(); - - await em.persistAndFlush([course, teacherUnkownToCourse.account, teacherUnkownToCourse.user]); - em.clear(); - - const loggedInClient = await testApiClient.login(teacherUnkownToCourse.account); - const response = await loggedInClient.get(`${course.id}`); - expect(response.statusCode).toEqual(404); - }); - it('should not find course if id does not exist', async () => { - const { teacher, course } = setup(); - const unknownId = new ObjectId().toHexString(); - - await em.persistAndFlush([course, teacher.account, teacher.user]); - em.clear(); - - const loggedInClient = await testApiClient.login(teacher.account); - const response = await loggedInClient.get(`${unknownId}`); - expect(response.statusCode).toEqual(404); - }); - it('should find course without start and until date', async () => { - const { courseWithoutStartAndUntilDate, teacher } = setup(); - - await em.persistAndFlush([courseWithoutStartAndUntilDate, teacher.account, teacher.user]); - em.clear(); - - const loggedInClient = await testApiClient.login(teacher.account); - const response = await loggedInClient.get(`${courseWithoutStartAndUntilDate.id}`); - const courseResponse = response.body as CourseResponse; - - expect(response.statusCode).toEqual(200); - expect(courseResponse).toBeDefined(); - expect(courseResponse.id).toEqual(courseWithoutStartAndUntilDate.id); - expect(courseResponse.students?.length).toEqual(2); - expect(courseResponse.startDate).toBeUndefined(); - expect(courseResponse.untilDate).toBeUndefined(); - }); - }); - describe('[GET] /courses/:id/export', () => { const setup = () => { const student1 = createStudent(); diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 8b953ef4341..14ae2b595f0 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -48,12 +48,4 @@ export class CourseController { }); return new StreamableFile(result); } - - @Get(':courseId') - async getCourse(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: CourseUrlParams) { - const course = await this.courseUc.getCourse(currentUser.userId, urlParams.courseId); - const response = CourseMapper.mapToCourseResponse(course); - - return response; - } } diff --git a/apps/server/src/modules/learnroom/controller/dto/course-response.spec.ts b/apps/server/src/modules/learnroom/controller/dto/course-response.spec.ts deleted file mode 100644 index 941a2145884..00000000000 --- a/apps/server/src/modules/learnroom/controller/dto/course-response.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { courseFactory, setupEntities } from '@shared/testing'; -import { CourseResponse } from './course.response'; - -describe('CourseResponse', () => { - beforeAll(async () => { - await setupEntities(); - }); - const setup = () => { - const course = courseFactory.buildWithId({ startDate: new Date(), untilDate: new Date() }); - return { course }; - }; - it('should create a CourseResponseDTO object', () => { - const { course } = setup(); - const courseResponse = new CourseResponse(course); - expect(courseResponse.id).toEqual(courseResponse.id); - expect(courseResponse.title).toEqual(courseResponse.title); - expect(courseResponse.startDate).toEqual(courseResponse.startDate); - expect(courseResponse.untilDate).toEqual(courseResponse.untilDate); - expect(courseResponse.students).toEqual(courseResponse.students); - }); - it('properties schould be undefined if not set', () => { - const { course } = setup(); - course.startDate = undefined; - course.untilDate = undefined; - const courseResponse = new CourseResponse(course); - expect(courseResponse.startDate).toBeUndefined(); - expect(courseResponse.untilDate).toBeUndefined(); - }); -}); diff --git a/apps/server/src/modules/learnroom/controller/dto/course.response.ts b/apps/server/src/modules/learnroom/controller/dto/course.response.ts deleted file mode 100644 index 0bead70a42c..00000000000 --- a/apps/server/src/modules/learnroom/controller/dto/course.response.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Course, UsersList } from '@shared/domain'; - -export class CourseResponse { - constructor(course: Course) { - this.id = course.id; - this.title = course.name; - if (course.startDate) { - this.startDate = course.startDate; - } - if (course.untilDate) { - this.untilDate = course.untilDate; - } - if (course.students) { - this.students = course.getStudentsList(); - } - } - - @ApiProperty({ - description: 'The id of the Grid element', - pattern: '[a-f0-9]{24}', - }) - id: string; - - @ApiProperty({ - description: 'Title of the Grid element', - }) - title: string; - - @ApiPropertyOptional({ - description: 'Start date of the course', - }) - startDate?: Date; - - @ApiPropertyOptional({ - description: 'End date of the course. After this the course counts as archived', - }) - untilDate?: Date; - - @ApiPropertyOptional({ - description: 'List of students enrolled in course', - type: [UsersList], - }) - students?: UsersList[]; -} diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index 92d3a7bae20..eb17ba3a672 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -1,4 +1,3 @@ -export * from './course.response'; export * from './course.url.params'; export * from './course-metadata.response'; export * from './dashboard.response'; diff --git a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-task.response.ts b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-task.response.ts index e6f27668214..c0bc66c20e6 100644 --- a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-task.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-task.response.ts @@ -43,7 +43,4 @@ export class BoardTaskResponse { @ApiProperty() status: BoardTaskStatusResponse; - - @ApiPropertyOptional() - taskCardId?: string; } diff --git a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts index 731949e5def..6a1e959452b 100644 --- a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts @@ -4,11 +4,12 @@ import { BoardElementResponse } from './board-element.response'; // TODO: this and DashboardResponse should be combined export class SingleColumnBoardResponse { - constructor({ roomId, title, displayColor, elements }: SingleColumnBoardResponse) { + constructor({ roomId, title, displayColor, elements, isArchived }: SingleColumnBoardResponse) { this.roomId = roomId; this.title = title; this.displayColor = displayColor; this.elements = elements; + this.isArchived = isArchived; } @ApiProperty({ @@ -33,4 +34,9 @@ export class SingleColumnBoardResponse { description: 'Array of board specific tasks or lessons with matching type property', }) elements: BoardElementResponse[]; + + @ApiProperty({ + description: 'Boolean if the room this board belongs to is archived', + }) + isArchived: boolean; } diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts index 3578966b8dc..5692cc3427b 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts @@ -75,7 +75,13 @@ describe('rooms controller', () => { const setup = () => { const currentUser = { userId: 'userId' } as ICurrentUser; - const ucResult = { roomId: 'id', title: 'title', displayColor: '#FFFFFF', elements: [] } as RoomBoardDTO; + const ucResult = { + roomId: 'id', + title: 'title', + displayColor: '#FFFFFF', + elements: [], + isArchived: false, + } as RoomBoardDTO; const ucSpy = jest.spyOn(uc, 'getBoard').mockImplementation(() => Promise.resolve(ucResult)); const mapperResult = new SingleColumnBoardResponse({ @@ -83,6 +89,7 @@ describe('rooms controller', () => { title: 'title', displayColor: '#FFFFFF', elements: [], + isArchived: false, }); const mapperSpy = jest.spyOn(mapper, 'mapToResponse').mockImplementation(() => mapperResult); return { currentUser, ucResult, ucSpy, mapperResult, mapperSpy }; diff --git a/apps/server/src/modules/learnroom/mapper/course.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/course.mapper.spec.ts deleted file mode 100644 index 3b5d46986b8..00000000000 --- a/apps/server/src/modules/learnroom/mapper/course.mapper.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { courseFactory, setupEntities } from '@shared/testing'; -import { CourseResponse } from '../controller/dto'; -import { CourseMapper } from './course.mapper'; - -describe('course mapper', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('mapToCourseResponse', () => { - it('should map task-card to response', () => { - const course = courseFactory.buildWithId(); - - const result: CourseResponse = CourseMapper.mapToCourseResponse(course); - - expect(result).toEqual({ - id: course.id, - title: course.name, - startDate: course.startDate, - untilDate: course.untilDate, - students: course.getStudentsList(), - }); - }); - it('date fields should be undefined', () => { - const course = courseFactory.buildWithId({ - startDate: undefined, - untilDate: undefined, - }); - - const result: CourseResponse = CourseMapper.mapToCourseResponse(course); - - expect(result.startDate).toBeUndefined(); - expect(result.untilDate).toBeUndefined(); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/mapper/course.mapper.ts b/apps/server/src/modules/learnroom/mapper/course.mapper.ts index 99b8308f8dd..22ebaf45d23 100644 --- a/apps/server/src/modules/learnroom/mapper/course.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/course.mapper.ts @@ -1,5 +1,5 @@ import { Course } from '@shared/domain'; -import { CourseMetadataResponse, CourseResponse } from '../controller/dto'; +import { CourseMetadataResponse } from '../controller/dto'; export class CourseMapper { static mapToMetadataResponse(course: Course): CourseMetadataResponse { @@ -15,9 +15,4 @@ export class CourseMapper { ); return dto; } - - static mapToCourseResponse(course: Course): CourseResponse { - const dto = new CourseResponse(course); - return dto; - } } diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts index 12ee57aba3c..9503a7c82ab 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { courseFactory, setupEntities, taskFactory } from '@shared/testing'; import { ObjectId } from 'bson'; -import { BoardElementResponse, BoardTaskResponse, SingleColumnBoardResponse } from '../controller/dto'; +import { BoardElementResponse, SingleColumnBoardResponse } from '../controller/dto'; import { RoomBoardElementTypes } from '../types'; import { RoomBoardResponseMapper } from './room-board-response.mapper'; @@ -30,6 +30,7 @@ describe('room board response mapper', () => { displayColor: '#ACACAC', title: 'boardTitle', elements: [], + isArchived: false, }; const result = mapper.mapToResponse(board); @@ -53,6 +54,7 @@ describe('room board response mapper', () => { displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.TASK, content: { task, status } }], + isArchived: false, }; const result = mapper.mapToResponse(board); @@ -60,11 +62,9 @@ describe('room board response mapper', () => { expect(result.elements[0] instanceof BoardElementResponse).toEqual(true); }); - it('should map tasks with status and its task card on board to response', () => { + it('should map tasks with status on board to response', () => { const course = courseFactory.buildWithId(); const linkedTask = taskFactory.buildWithId({ course }); - const mockTaskCardId = 'taskCardId #1'; - linkedTask.taskCard = mockTaskCardId; const status = { graded: 0, maxSubmissions: 0, @@ -78,12 +78,12 @@ describe('room board response mapper', () => { displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.TASK, content: { task: linkedTask, status } }], + isArchived: false, }; const result = mapper.mapToResponse(board); expect(result.elements[0] instanceof BoardElementResponse).toEqual(true); - expect((result.elements[0].content as BoardTaskResponse).taskCardId).toEqual(mockTaskCardId); }); it('should map lessons on board to response', () => { @@ -104,6 +104,7 @@ describe('room board response mapper', () => { displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.LESSON, content: lessonMetadata }], + isArchived: false, }; const result = mapper.mapToResponse(board); @@ -141,6 +142,7 @@ describe('room board response mapper', () => { { type: RoomBoardElementTypes.LESSON, content: lessonMetadata }, { type: RoomBoardElementTypes.TASK, content: { task, status } }, ], + isArchived: false, }; const result = mapper.mapToResponse(board); @@ -163,6 +165,7 @@ describe('room board response mapper', () => { displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.COLUMN_BOARD, content: columnBoardMetaData }], + isArchived: false, }; const result = mapper.mapToResponse(board); diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts index da96155a632..beadf1b0e3b 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts @@ -20,6 +20,7 @@ export class RoomBoardResponseMapper { title: board.title, displayColor: board.displayColor, elements, + isArchived: board.isArchived, }); return mapped; @@ -56,10 +57,6 @@ export class RoomBoardResponseMapper { status: boardTaskStatus, }); - if (boardTask.taskCard) { - mappedTask.taskCardId = boardTask.taskCard; - } - const taskCourse = boardTask.course as Course; mappedTask.courseName = taskCourse.name; mappedTask.availableDate = boardTask.availableDate; 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 b78524ed5a4..59a6169a2a3 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 @@ -62,5 +62,15 @@ describe(ColumnBoardTargetService.name, () => { expect(result[0].title).toEqual('board #42'); }); }); + + describe('when no target exists for columnBoardId', () => { + it('should create a target', async () => { + const id = new ObjectId().toHexString(); + columnBoardService.getBoardObjectTitlesById.mockResolvedValueOnce({ [id]: 'board #42' }); + const result = await service.findOrCreateTargets([id]); + + expect(result[0].columnBoardId).toEqual(id); + }); + }); }); }); diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts index 65200849d7f..8055a619273 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts @@ -70,7 +70,7 @@ describe('CommonCartridgeExportService', () => { {} as IComponentProperties, ], }); - tasks = taskFactory.buildList(5); + tasks = taskFactory.buildListWithId(5); }); afterAll(async () => { @@ -132,13 +132,14 @@ describe('CommonCartridgeExportService', () => { expect(manifest).toContain(course.teachers[1].lastName); expect(manifest).toContain(course.createdAt.getFullYear().toString()); }); - // TODO: will be done in EW-526: https://ticketsystem.dbildungscloud.de/browse/EW-526 - // it('should add tasks as assignments', () => { - // const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - // tasks.forEach((task) => { - // expect(manifest).toContain(`i${task.id}`); - // }); - // }); + + it('should add tasks as assignments', () => { + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + tasks.forEach((task) => { + expect(manifest).toContain(`${task.name}`); + expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="unspecified"`); + }); + }); it('should add version 1 information to manifest file', () => { const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); @@ -182,6 +183,14 @@ describe('CommonCartridgeExportService', () => { expect(manifest).toContain(course.createdAt.getFullYear().toString()); }); + it('should add tasks as assignments', () => { + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + tasks.forEach((task) => { + expect(manifest).toContain(`${task.name}`); + expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="assignment"`); + }); + }); + it('should add version 3 information to manifest file', () => { const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); expect(manifest).toContain(CommonCartridgeVersion.V_1_3_0); diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts index cdb5dc62aeb..25077d84338 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts @@ -1,32 +1,58 @@ import { Injectable } from '@nestjs/common'; -import { Course, EntityId, IComponentProperties, Lesson } from '@shared/domain'; +import { Course, EntityId, IComponentProperties, Task } from '@shared/domain'; import { LessonService } from '@src/modules/lesson/service'; import { ComponentType } from '@src/shared/domain/entity/lesson.entity'; +import { TaskService } from '@src/modules/task/service'; import { CommonCartridgeFileBuilder, + CommonCartridgeIntendedUseType, CommonCartridgeResourceType, CommonCartridgeVersion, - ICommonCartridgeOrganizationProps, ICommonCartridgeResourceProps, + ICommonCartridgeWebContentResourceProps, } from '../common-cartridge'; import { CourseService } from './course.service'; +import { createIdentifier } from '../common-cartridge/utils'; @Injectable() export class CommonCartridgeExportService { - constructor(private readonly courseService: CourseService, private readonly lessonService: LessonService) {} + constructor( + private readonly courseService: CourseService, + private readonly lessonService: LessonService, + private readonly taskService: TaskService + ) {} async exportCourse(courseId: EntityId, userId: EntityId, version: CommonCartridgeVersion): Promise { const course = await this.courseService.findById(courseId); - const [lessons] = await this.lessonService.findByCourseIds([courseId]); const builder = new CommonCartridgeFileBuilder({ - identifier: `i${course.id}`, + identifier: createIdentifier(courseId), title: course.name, version, copyrightOwners: this.mapCourseTeachersToCopyrightOwners(course), creationYear: course.createdAt.getFullYear().toString(), }); + + await this.addLessons(builder, version, courseId); + await this.addTasks(builder, version, courseId, userId); + + return builder.build(); + } + + private async addLessons( + builder: CommonCartridgeFileBuilder, + version: CommonCartridgeVersion, + courseId: EntityId + ): Promise { + const [lessons] = await this.lessonService.findByCourseIds([courseId]); + lessons.forEach((lesson) => { - const organizationBuilder = builder.addOrganization(this.mapLessonToOrganization(lesson, version)); + const organizationBuilder = builder.addOrganization({ + version, + identifier: createIdentifier(lesson.id), + title: lesson.name, + resources: [], + }); + lesson.contents.forEach((content) => { const resourceProps = this.mapContentToResource(lesson.id, content, version); if (resourceProps) { @@ -34,27 +60,26 @@ export class CommonCartridgeExportService { } }); }); - - // TODO: add tasks as assignments, will be done in EW-526: https://ticketsystem.dbildungscloud.de/browse/EW-526 - // const [tasks] = await this.taskService.findBySingleParent(userId, courseId); - // const builder = new CommonCartridgeFileBuilder({ - // identifier: `i${course.id}`, - // title: course.name, - // }) - // .addOrganizationItems(this.mapLessonsToOrganizationItems(lessons)) - // .addAssignments(this.mapTasksToAssignments(tasks)); - // return builder.build(); - - return builder.build(); } - private mapLessonToOrganization(lesson: Lesson, version: CommonCartridgeVersion): ICommonCartridgeOrganizationProps { - return { - identifier: `i${lesson.id}`, + private async addTasks( + builder: CommonCartridgeFileBuilder, + version: CommonCartridgeVersion, + courseId: EntityId, + userId: EntityId + ): Promise { + const [tasks] = await this.taskService.findBySingleParent(userId, courseId); + const organizationBuilder = builder.addOrganization({ version, - title: lesson.name, + identifier: createIdentifier(), + // FIXME: change the title for tasks organization + title: '', resources: [], - }; + }); + + tasks.forEach((task) => { + organizationBuilder.addResourceToOrganization(this.mapTaskToWebContentResource(task, version)); + }); } private mapContentToResource( @@ -64,18 +89,19 @@ export class CommonCartridgeExportService { ): ICommonCartridgeResourceProps | undefined { const commonProps = { version, - identifier: `i${content._id as string}`, - href: `i${lessonId}/i${content._id as string}.xml`, + identifier: createIdentifier(content._id), + href: `${createIdentifier(lessonId)}/${createIdentifier(content._id)}.html`, title: content.title, }; if (content.component === ComponentType.TEXT) { return { version, - identifier: `i${content._id as string}`, - href: `i${lessonId}/i${content._id as string}.html`, + identifier: createIdentifier(content._id), + href: `${createIdentifier(lessonId)}/${createIdentifier(content._id)}.html`, title: content.title, type: CommonCartridgeResourceType.WEB_CONTENT, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, html: `

${content.title}

${content.content.text}

`, }; } @@ -108,4 +134,23 @@ export class CommonCartridgeExportService { .reduce((previousTeachers, currentTeacher) => `${previousTeachers}, ${currentTeacher}`); return result; } + + private mapTaskToWebContentResource( + task: Task, + version: CommonCartridgeVersion + ): ICommonCartridgeWebContentResourceProps { + const taskIdentifier = createIdentifier(task.id); + return { + version, + identifier: taskIdentifier, + href: `${taskIdentifier}/${taskIdentifier}.html`, + title: task.name, + type: CommonCartridgeResourceType.WEB_CONTENT, + html: `

${task.name}

${task.description}

`, + intendedUse: + version === CommonCartridgeVersion.V_1_1_0 + ? CommonCartridgeIntendedUseType.UNSPECIFIED + : CommonCartridgeIntendedUseType.ASSIGNMENT, + }; + } } diff --git a/apps/server/src/modules/learnroom/types/room-board.types.ts b/apps/server/src/modules/learnroom/types/room-board.types.ts index a48ffd0585a..bcb5baab097 100644 --- a/apps/server/src/modules/learnroom/types/room-board.types.ts +++ b/apps/server/src/modules/learnroom/types/room-board.types.ts @@ -5,6 +5,7 @@ export type RoomBoardDTO = { displayColor: string; title: string; elements: RoomBoardElementDTO[]; + isArchived: boolean; }; export enum RoomBoardElementTypes { diff --git a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts index a74e0d074ea..750c9b1a287 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts @@ -1,18 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Course, Permission, SortOrder } from '@shared/domain'; +import { SortOrder } from '@shared/domain'; import { CourseRepo, LessonRepo } from '@shared/repo'; -import { courseFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationService } from '@src/modules'; -import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { courseFactory, setupEntities } from '@shared/testing'; import { CourseUc } from './course.uc'; describe('CourseUc', () => { let module: TestingModule; let uc: CourseUc; let courseRepo: DeepMocked; - let authorizationService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -27,16 +23,11 @@ describe('CourseUc', () => { provide: LessonRepo, useValue: createMock(), }, - { - provide: AuthorizationService, - useValue: createMock(), - }, ], }).compile(); uc = module.get(CourseUc); courseRepo = module.get(CourseRepo); - authorizationService = module.get(AuthorizationService); }); afterAll(async () => { @@ -68,43 +59,4 @@ describe('CourseUc', () => { expect(courseRepo.findAllByUserId).toHaveBeenCalledWith('someUserId', {}, resultingOptions); }); }); - describe('getCourse', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const course = courseFactory.build(); - return { user, course }; - }; - it('should return course for teacher', async () => { - const { user, course } = setup(); - - courseRepo.findOneForTeacherOrSubstituteTeacher.mockResolvedValueOnce(course); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - const result: Course = await uc.getCourse(user.id, course.id); - expect(result).toEqual(course); - }); - it('should check for permission to edit course', async () => { - const { user, course } = setup(); - - courseRepo.findOneForTeacherOrSubstituteTeacher.mockResolvedValueOnce(course); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - await uc.getCourse(user.id, course.id); - expect(authorizationService.checkPermission).toBeCalledWith( - user, - course, - AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) - ); - }); - it('should throw error if user has no permission to edit course', async () => { - const { user, course } = setup(); - - courseRepo.findOneForTeacherOrSubstituteTeacher.mockResolvedValueOnce(course); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - authorizationService.checkPermission.mockImplementation(() => { - throw new ForbiddenException(); - }); - await expect(async () => { - await uc.getCourse(user.id, course.id); - }).rejects.toThrow(ForbiddenException); - }); - }); }); diff --git a/apps/server/src/modules/learnroom/uc/course.uc.ts b/apps/server/src/modules/learnroom/uc/course.uc.ts index 98d09ae865e..852bf14a3e0 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.ts @@ -1,27 +1,13 @@ import { Injectable } from '@nestjs/common'; import { PaginationParams } from '@shared/controller/'; -import { Counted, Course, EntityId, Permission, SortOrder } from '@shared/domain'; +import { Counted, Course, EntityId, SortOrder } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; -import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; @Injectable() export class CourseUc { - constructor(private readonly courseRepo: CourseRepo, private readonly authorizationService: AuthorizationService) {} + constructor(private readonly courseRepo: CourseRepo) {} findAllByUser(userId: EntityId, options?: PaginationParams): Promise> { return this.courseRepo.findAllByUserId(userId, {}, { pagination: options, order: { updatedAt: SortOrder.desc } }); } - - public async getCourse(userId: EntityId, courseId: EntityId) { - const user = await this.authorizationService.getUserWithPermissions(userId); - const course = await this.courseRepo.findOneForTeacherOrSubstituteTeacher(userId, courseId); - - this.authorizationService.checkPermission( - user, - course, - AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) - ); - - return course; - } } diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts index 86b1ea9bf15..b1c4b3dd2c8 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts @@ -177,6 +177,7 @@ class DtoCreator { displayColor: this.room.color, title: this.room.name, elements, + isArchived: this.room.isFinished(), }; return dto; } diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts index 0f04d25e267..7c6b82aad84 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts @@ -83,11 +83,12 @@ describe('rooms usecase', () => { const tasks = taskFactory.buildList(3, { course: room }); const lessons = lessonFactory.buildList(3, { course: room }); const board = boardFactory.buildWithId({ course: room }); - const dto = { + const roomBoardDTO = { roomId: room.id, displayColor: room.color, title: room.name, elements: [], + isArchived: room.isFinished(), }; board.syncBoardElementReferences([...lessons, ...tasks]); @@ -98,7 +99,7 @@ describe('rooms usecase', () => { const tasksSpy = taskRepo.findBySingleParent.mockResolvedValue([tasks, 3]); const lessonsSpy = lessonRepo.findAllByCourseIds.mockResolvedValue([lessons, 3]); const syncBoardElementReferencesSpy = jest.spyOn(board, 'syncBoardElementReferences'); - const mapperSpy = factory.createDTO.mockReturnValue(dto); + const mapperSpy = factory.createDTO.mockReturnValue(roomBoardDTO); const saveSpy = boardRepo.save.mockResolvedValue(); roomsService.updateBoard.mockResolvedValue(board); @@ -108,7 +109,7 @@ describe('rooms usecase', () => { room, tasks, lessons, - dto, + roomBoardDTO, userSpy, roomSpy, boardSpy, @@ -145,9 +146,9 @@ describe('rooms usecase', () => { }); it('should return result dto', async () => { - const { room, user, dto } = setup(); + const { room, user, roomBoardDTO } = setup(); const result = await uc.getBoard(room.id, user.id); - expect(result).toEqual(dto); + expect(result).toEqual(roomBoardDTO); }); it('should ensure course has uptodate board', async () => { diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.ts b/apps/server/src/modules/learnroom/uc/rooms.uc.ts index 9e945528e9b..8d3e31c15f7 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.uc.ts @@ -24,8 +24,8 @@ export class RoomsUc { await this.roomsService.updateBoard(board, roomId, userId); - const dto = this.factory.createDTO({ room: course, board, user }); - return dto; + const roomBoardDTO = this.factory.createDTO({ room: course, board, user }); + return roomBoardDTO; } async updateVisibilityOfBoardElement( diff --git a/apps/server/src/modules/management/seed-data/roles.ts b/apps/server/src/modules/management/seed-data/roles.ts index 452f7d867bc..f5a4ee0fbdc 100644 --- a/apps/server/src/modules/management/seed-data/roles.ts +++ b/apps/server/src/modules/management/seed-data/roles.ts @@ -210,8 +210,6 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { Permission.USERGROUP_EDIT, Permission.USER_CREATE, Permission.TASK_DASHBOARD_TEACHER_VIEW_V3, - Permission.TASK_CARD_VIEW, - Permission.TASK_CARD_EDIT, Permission.TEAM_CREATE, Permission.TEAM_EDIT, Permission.START_MEETING, @@ -229,7 +227,6 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { permissions: [ Permission.TASK_DASHBOARD_VIEW_V3, Permission.JOIN_MEETING, - Permission.TASK_CARD_VIEW, Permission.TEAM_CREATE, Permission.TEAM_EDIT, Permission.TOOL_CREATE_ETHERPAD, diff --git a/apps/server/src/modules/news/mapper/news.mapper.spec.ts b/apps/server/src/modules/news/mapper/news.mapper.spec.ts index 20a810d8a29..eaa4635abc6 100644 --- a/apps/server/src/modules/news/mapper/news.mapper.spec.ts +++ b/apps/server/src/modules/news/mapper/news.mapper.spec.ts @@ -5,7 +5,7 @@ import { News, School, SchoolNews, - Team, + TeamEntity, TeamNews, User, NewsTargetModel, @@ -144,7 +144,7 @@ describe('NewsMapper', () => { }); it('should correctly map team news to dto', () => { const school = schoolFactory.build(); - const team = new Team({ name: 'team #1' }); + const team = new TeamEntity({ name: 'team #1' }); const creator = userFactory.build(); const newsProps = { title: 'test title', content: 'test content' }; const teamNews = createNews(newsProps, TeamNews, school, creator, team); diff --git a/apps/server/src/modules/news/uc/news.uc.ts b/apps/server/src/modules/news/uc/news.uc.ts index 7f6fd57eb2a..39aeb05f03b 100644 --- a/apps/server/src/modules/news/uc/news.uc.ts +++ b/apps/server/src/modules/news/uc/news.uc.ts @@ -52,7 +52,7 @@ export class NewsUc { }); await this.newsRepo.save(news); - this.logger.log(new NewsCrudOperationLoggable(CrudOperation.CREATE, userId, news)); + this.logger.info(new NewsCrudOperationLoggable(CrudOperation.CREATE, userId, news)); return news; } @@ -134,7 +134,7 @@ export class NewsUc { await this.newsRepo.save(news); - this.logger.log(new NewsCrudOperationLoggable(CrudOperation.UPDATE, userId, news)); + this.logger.info(new NewsCrudOperationLoggable(CrudOperation.UPDATE, userId, news)); return news; } @@ -151,7 +151,7 @@ export class NewsUc { await this.newsRepo.delete(news); - this.logger.log(new NewsCrudOperationLoggable(CrudOperation.DELETE, userId, news)); + this.logger.info(new NewsCrudOperationLoggable(CrudOperation.DELETE, userId, news)); return id; } 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 0900da07833..d963d247a93 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts @@ -5,12 +5,21 @@ import { LoggerModule } from '@src/core/logger'; import { LtiToolModule } from '@src/modules/lti-tool'; import { PseudonymModule } from '@src/modules/pseudonym'; import { ToolModule } from '@src/modules/tool'; +import { ToolConfigModule } from '@src/modules/tool/tool-config.module'; import { UserModule } from '@src/modules/user'; import { IdTokenService } from './service/id-token.service'; import { OauthProviderLoginFlowService } from './service/oauth-provider.login-flow.service'; @Module({ - imports: [OauthProviderServiceModule, UserModule, LoggerModule, PseudonymModule, LtiToolModule, ToolModule], + imports: [ + OauthProviderServiceModule, + UserModule, + LoggerModule, + PseudonymModule, + LtiToolModule, + ToolModule, + ToolConfigModule, + ], providers: [OauthProviderLoginFlowService, IdTokenService, TeamsRepo], exports: [OauthProviderLoginFlowService, IdTokenService], }) diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts index c73bf49134e..454874571f9 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts @@ -1,17 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, Pseudonym, Team, UserDO } from '@shared/domain'; +import { Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; -import { externalToolDOFactory, setupEntities, userDoFactory, pseudonymFactory } from '@shared/testing'; +import { externalToolFactory, pseudonymFactory, setupEntities, userDoFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; import { IdToken } from '@src/modules/oauth-provider/interface/id-token'; import { OauthScope } from '@src/modules/oauth-provider/interface/oauth-scope.enum'; import { IdTokenService } from '@src/modules/oauth-provider/service/id-token.service'; import { PseudonymService } from '@src/modules/pseudonym/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { UserService } from '@src/modules/user/service/user.service'; -import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; +import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; import resetAllMocks = jest.resetAllMocks; describe('IdTokenService', () => { @@ -82,9 +83,9 @@ describe('IdTokenService', () => { const displayName = 'display name'; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); + const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ pseudonym: 'pseudonym' }); + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); @@ -114,15 +115,15 @@ describe('IdTokenService', () => { describe('when scopes contain groups', () => { const setup = () => { - const team: Team = teamFactory.buildWithId(); + const team: TeamEntity = teamFactory.buildWithId(); const user: UserDO = userDoFactory.buildWithId({ schoolId: 'schoolId' }); const displayName = 'display name'; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); + const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ pseudonym: 'pseudonym' }); + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); teamsRepo.findByUserId.mockResolvedValue([team]); userService.findById.mockResolvedValue(user); @@ -164,9 +165,9 @@ describe('IdTokenService', () => { const displayName = 'display name'; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); + const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ pseudonym: 'pseudonym' }); + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); @@ -201,9 +202,9 @@ describe('IdTokenService', () => { const displayName = 'display name'; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); + const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ pseudonym: 'pseudonym' }); + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); @@ -239,9 +240,9 @@ describe('IdTokenService', () => { const displayName = 'display name'; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().build({ id: undefined }); + const tool: ExternalTool = externalToolFactory.withOauth2Config().build({ id: undefined }); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ pseudonym: 'pseudonym' }); + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.ts index 2cac0e9afe0..8eec9407d6c 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.ts @@ -1,9 +1,10 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Injectable } from '@nestjs/common'; -import { ExternalToolDO, LtiToolDO, Pseudonym, Team, UserDO } from '@shared/domain'; +import { LtiToolDO, Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; import { PseudonymService } from '@src/modules/pseudonym'; import { UserService } from '@src/modules/user'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { GroupNameIdTuple, IdToken, OauthScope } from '../interface'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; @@ -25,7 +26,7 @@ export class IdTokenService { } async createIdToken(userId: string, scopes: string[], clientId: string): Promise { - let teams: Team[] = []; + let teams: TeamEntity[] = []; if (scopes.includes(OauthScope.GROUPS)) { teams = await this.teamsRepo.findByUserId(userId); } @@ -45,8 +46,8 @@ export class IdTokenService { }; } - private buildGroupsClaim(teams: Team[]): GroupNameIdTuple[] { - return teams.map((team: Team): GroupNameIdTuple => { + private buildGroupsClaim(teams: TeamEntity[]): GroupNameIdTuple[] { + return teams.map((team: TeamEntity): GroupNameIdTuple => { return { gid: team.id, displayName: team.name, @@ -56,7 +57,7 @@ export class IdTokenService { // TODO N21-335 How we can refactor the iframe in the id token? private async createIframeSubject(user: UserDO, clientId: string): Promise { - const tool: ExternalToolDO | LtiToolDO = await this.oauthProviderLoginFlowService.findToolByClientId(clientId); + const tool: ExternalTool | LtiToolDO = await this.oauthProviderLoginFlowService.findToolByClientId(clientId); if (!tool.id) { throw new IdTokenCreationLoggableException(clientId, user.id); diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts index de575d89ef1..3d382fffe3b 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts @@ -1,10 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, LtiToolDO } from '@shared/domain'; -import { externalToolDOFactory, ltiToolDOFactory, setupEntities } from '@shared/testing'; +import { LtiToolDO } from '@shared/domain'; +import { externalToolFactory, ltiToolDOFactory, setupEntities } from '@shared/testing'; import { LtiToolService } from '@src/modules/lti-tool'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { IToolFeatures, ToolFeatures } from '@src/modules/tool/tool-config'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; describe('OauthProviderLoginFlowService', () => { @@ -13,6 +15,7 @@ describe('OauthProviderLoginFlowService', () => { let ltiToolService: DeepMocked; let externalToolService: DeepMocked; + let toolFeatures: IToolFeatures; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +29,19 @@ describe('OauthProviderLoginFlowService', () => { provide: ExternalToolService, useValue: createMock(), }, + { + provide: ToolFeatures, + useValue: { + ctlToolsTabEnabled: false, + }, + }, ], }).compile(); service = module.get(OauthProviderLoginFlowService); ltiToolService = module.get(LtiToolService); externalToolService = module.get(ExternalToolService); + toolFeatures = module.get(ToolFeatures); await setupEntities(); }); @@ -40,10 +50,16 @@ describe('OauthProviderLoginFlowService', () => { await module.close(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('findToolByClientId', () => { - describe('when it finds a ctl tool', () => { + describe('when it finds a ctl tool and the ctl feature is active', () => { const setup = () => { - const tool: ExternalToolDO = externalToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); + toolFeatures.ctlToolsTabEnabled = true; + + const tool: ExternalTool = externalToolFactory.buildWithId({ name: 'SchulcloudNextcloud' }); externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(tool); ltiToolService.findByClientIdAndIsLocal.mockResolvedValue(null); @@ -56,14 +72,46 @@ describe('OauthProviderLoginFlowService', () => { it('should return a ctl tool', async () => { const { tool } = setup(); - const result: ExternalToolDO | LtiToolDO = await service.findToolByClientId('clientId'); + const result: ExternalTool | LtiToolDO = await service.findToolByClientId('clientId'); expect(result).toEqual(tool); }); }); - describe('when it finds a lti tool', () => { + describe('when a lti tool exists and the ctl feature is deactivated', () => { const setup = () => { + toolFeatures.ctlToolsTabEnabled = false; + + const tool: LtiToolDO = ltiToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); + + ltiToolService.findByClientIdAndIsLocal.mockResolvedValue(tool); + + return { + tool, + }; + }; + + it('should not search for ctl tools', async () => { + setup(); + + await service.findToolByClientId('clientId'); + + expect(externalToolService.findExternalToolByOAuth2ConfigClientId).not.toHaveBeenCalled(); + }); + + it('should find a lti tool', async () => { + const { tool } = setup(); + + const result = await service.findToolByClientId('clientId'); + + expect(result).toEqual(tool); + }); + }); + + describe('when a lti tool exists and the ctl feature is active and no ctl tool exists', () => { + const setup = () => { + toolFeatures.ctlToolsTabEnabled = true; + const tool: LtiToolDO = ltiToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); @@ -74,17 +122,27 @@ describe('OauthProviderLoginFlowService', () => { }; }; + it('should search for the ctl tool', async () => { + setup(); + + await service.findToolByClientId('clientId'); + + expect(externalToolService.findExternalToolByOAuth2ConfigClientId).toHaveBeenCalled(); + }); + it('should return a lti tool', async () => { const { tool } = setup(); - const result: ExternalToolDO | LtiToolDO = await service.findToolByClientId('clientId'); + const result: ExternalTool | LtiToolDO = await service.findToolByClientId('clientId'); expect(result).toEqual(tool); }); }); - describe('when no tool was found', () => { + describe('when no lti or ctl tool was found', () => { const setup = () => { + toolFeatures.ctlToolsTabEnabled = true; + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); ltiToolService.findByClientIdAndIsLocal.mockResolvedValue(null); }; @@ -104,7 +162,7 @@ describe('OauthProviderLoginFlowService', () => { describe('isNextcloudTool', () => { describe('when it is Nextcloud', () => { const setup = () => { - const tool: ExternalToolDO = externalToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); + const tool: ExternalTool = externalToolFactory.buildWithId({ name: 'SchulcloudNextcloud' }); return { tool, @@ -122,7 +180,7 @@ describe('OauthProviderLoginFlowService', () => { describe('when it is not Nextcloud', () => { const setup = () => { - const tool: ExternalToolDO = externalToolDOFactory.buildWithId({ name: 'NotSchulcloudNextcloud' }); + const tool: ExternalTool = externalToolFactory.buildWithId({ name: 'NotSchulcloudNextcloud' }); return { tool, diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts index f992ecb8077..367efdcf6f7 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts +++ b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts @@ -1,27 +1,33 @@ +import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; -import { ExternalToolDO } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { LtiToolService } from '@src/modules/lti-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { IToolFeatures, ToolFeatures } from '@src/modules/tool/tool-config'; @Injectable() export class OauthProviderLoginFlowService { constructor( private readonly ltiToolService: LtiToolService, - private readonly externalToolService: ExternalToolService + private readonly externalToolService: ExternalToolService, + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures ) {} - public async findToolByClientId(clientId: string): Promise { - const externalTool: ExternalToolDO | null = await this.externalToolService.findExternalToolByOAuth2ConfigClientId( - clientId - ); - const ltiTool: LtiToolDO | null = await this.ltiToolService.findByClientIdAndIsLocal(clientId, true); + public async findToolByClientId(clientId: string): Promise { + if (this.toolFeatures.ctlToolsTabEnabled) { + const externalTool: ExternalTool | null = await this.externalToolService.findExternalToolByOAuth2ConfigClientId( + clientId + ); - if (externalTool) { - return externalTool; + if (externalTool) { + return externalTool; + } } + const ltiTool: LtiToolDO | null = await this.ltiToolService.findByClientIdAndIsLocal(clientId, true); + if (ltiTool) { return ltiTool; } @@ -30,7 +36,7 @@ export class OauthProviderLoginFlowService { } // TODO N21-91. Magic Strings are not desireable - public isNextcloudTool(tool: ExternalToolDO | LtiToolDO): boolean { + public isNextcloudTool(tool: ExternalTool | LtiToolDO): boolean { const isNextcloud: boolean = tool.name === 'SchulcloudNextcloud'; return isNextcloud; 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 c4115ac4e90..e95aea26594 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 @@ -1,11 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, LtiToolDO, Permission, Pseudonym, UserDO } from '@shared/domain'; +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 { - externalToolDOFactory, + externalToolFactory, ltiToolDOFactory, pseudonymFactory, setupEntities, @@ -14,6 +14,7 @@ import { } from '@shared/testing'; import { AuthorizationService } from '@src/modules/authorization'; import { PseudonymService } from '@src/modules/pseudonym'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { UserService } from '@src/modules/user'; import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from '../controller/dto'; import { OauthProviderLoginFlowService } from '../service/oauth-provider.login-flow.service'; @@ -137,7 +138,7 @@ describe('OauthProviderLoginFlowUc', () => { }; const user: UserDO = userDoFactory.buildWithId(); - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config({ skipConsent: true }).buildWithId(); + const tool: ExternalTool = externalToolFactory.withOauth2Config({ skipConsent: true }).buildWithId(); oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); @@ -289,9 +290,7 @@ describe('OauthProviderLoginFlowUc', () => { subject: 'subject', }; - const tool: ExternalToolDO = externalToolDOFactory - .withOauth2Config() - .buildWithId({ name: 'SchulcloudNextcloud' }); + const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId({ name: 'SchulcloudNextcloud' }); const user = userFactory.buildWithId(); @@ -381,7 +380,7 @@ describe('OauthProviderLoginFlowUc', () => { subject: 'subject', }; - const tool: ExternalToolDO = externalToolDOFactory.withOauth2Config().build({ id: undefined }); + const tool: ExternalTool = externalToolFactory.withOauth2Config().build({ id: undefined }); oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); @@ -424,7 +423,7 @@ describe('OauthProviderLoginFlowUc', () => { subject: 'subject', }; - const tool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const tool: ExternalTool = externalToolFactory.buildWithId(); oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); 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 64da2f72c48..8901b506f99 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,5 +1,5 @@ import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; -import { ExternalToolDO, Oauth2ToolConfigDO, Permission, Pseudonym, User, UserDO } from '@shared/domain'; +import { Permission, Pseudonym, User, UserDO } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { OauthProviderService } from '@shared/infra/oauth-provider'; import { @@ -11,6 +11,7 @@ import { AuthorizationService } from '@src/modules/authorization'; import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from '@src/modules/oauth-provider/controller/dto'; import { OauthProviderRequestMapper } from '@src/modules/oauth-provider/mapper/oauth-provider-request.mapper'; import { PseudonymService } from '@src/modules/pseudonym/service'; +import { ExternalTool, Oauth2ToolConfig } from '@src/modules/tool/external-tool/domain'; import { UserService } from '@src/modules/user'; import { OauthProviderLoginFlowService } from '../service/oauth-provider.login-flow.service'; @@ -55,7 +56,7 @@ export class OauthProviderLoginFlowUc { throw new InternalServerErrorException(`Cannot find oAuthClientId in login response for challenge: ${challenge}`); } - const tool: ExternalToolDO | LtiToolDO = await this.oauthProviderLoginFlowService.findToolByClientId( + const tool: ExternalTool | LtiToolDO = await this.oauthProviderLoginFlowService.findToolByClientId( loginResponse.client.client_id ); @@ -90,11 +91,11 @@ export class OauthProviderLoginFlowUc { return redirectResponse; } - private shouldSkipConsent(tool: ExternalToolDO | LtiToolDO): boolean { + private shouldSkipConsent(tool: ExternalTool | LtiToolDO): boolean { if (tool instanceof LtiToolDO) { return !!tool.skipConsent; } - if (tool.config instanceof Oauth2ToolConfigDO) { + if (tool.config instanceof Oauth2ToolConfig) { return tool.config.skipConsent; } throw new UnprocessableEntityException( 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 index 03541d2074f..af29ff68b08 100644 --- 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 @@ -18,7 +18,7 @@ import { JwtTestFactory } from '@shared/testing/factory/jwt.test.factory'; import { userLoginMigrationFactory } from '@shared/testing/factory/user-login-migration.factory'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { SanisResponse, SanisRole } from '@src/modules/provisioning'; +import { SanisResponse, SanisRole } from '@src/modules/provisioning/strategy/sanis/response'; import { ServerTestModule } from '@src/modules/server'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -292,16 +292,15 @@ describe('OAuth SSO Controller (API)', () => { }, personenkontexte: [ { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713'), + id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), rolle: SanisRole.LEHR, organisation: { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713'), + id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), kennung: officialSchoolNumber, name: 'schulName', typ: 'not necessary', }, personenstatus: 'not necessary', - email: 'email', }, ], }); @@ -484,7 +483,7 @@ describe('OAuth SSO Controller (API)', () => { }; it('should redirect to the general migration error page', async () => { - const { targetSystem, sourceUser, sourceSystem, query, cookies } = await setupMigration(); + 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; @@ -494,10 +493,7 @@ describe('OAuth SSO Controller (API)', () => { .set('Cookie', cookies) .query(query) .expect(302) - .expect( - 'Location', - `${baseUrl}/migration/error?sourceSystem=${sourceSystem.id}&targetSystem=${targetSystem.id}` - ); + .expect('Location', `${baseUrl}/migration/error`); }); }); @@ -575,10 +571,7 @@ describe('OAuth SSO Controller (API)', () => { .set('Cookie', cookies) .query(query) .expect(302) - .expect( - 'Location', - `${baseUrl}/migration/error?sourceSystem=${sourceSystem.id}&targetSystem=${targetSystem.id}&sourceSchoolNumber=11111&targetSchoolNumber=22222` - ); + .expect('Location', `${baseUrl}/migration/error?sourceSchoolNumber=11111&targetSchoolNumber=22222`); }); }); 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 31c50af48ac..934f7c26dc6 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -63,13 +63,7 @@ export class OauthSSOController { res.redirect(errorRedirect.toString()); } - private migrationErrorHandler( - error: unknown, - session: ISession, - res: Response, - sourceSystemId: string, - targetSystemId: string - ) { + private migrationErrorHandler(error: unknown, session: ISession, res: Response) { const migrationError: OAuthMigrationError = error instanceof OAuthMigrationError ? error : new OAuthMigrationError(); @@ -79,9 +73,6 @@ export class OauthSSOController { const errorRedirect: URL = new URL('/migration/error', this.clientUrl); - errorRedirect.searchParams.append('sourceSystem', sourceSystemId); - errorRedirect.searchParams.append('targetSystem', targetSystemId); - if (migrationError.officialSchoolNumberFromSource && migrationError.officialSchoolNumberFromTarget) { errorRedirect.searchParams.append('sourceSchoolNumber', migrationError.officialSchoolNumberFromSource); errorRedirect.searchParams.append('targetSchoolNumber', migrationError.officialSchoolNumberFromTarget); @@ -208,7 +199,7 @@ export class OauthSSOController { const response: UserMigrationResponse = UserMigrationMapper.mapDtoToResponse(migration); res.redirect(response.redirect); } catch (error) { - this.migrationErrorHandler(error, session, res, currentUser.systemId, oauthLoginState.systemId); + this.migrationErrorHandler(error, session, res); } } } 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 7f17d1e72c9..4912bc039ca 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.spec.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.spec.ts @@ -8,11 +8,12 @@ import { LtiPrivacyPermission, LtiRoleType, OauthConfig } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@shared/infra/encryption'; import { LtiToolRepo } from '@shared/repo'; +import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { CookiesDto } from '@src/modules/oauth/service/dto/cookies.dto'; import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; import { HydraSsoService } from '@src/modules/oauth/service/hydra.service'; -import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; @@ -28,15 +29,10 @@ jest.mock('nanoid', () => { }; }); -const createAxiosResponse = (data: T): AxiosResponse => { - return { +const createAxiosResponse = (data: T) => + axiosResponseFactory.build({ data, - status: 0, - statusText: '', - headers: {}, - config: {}, - }; -}; + }); describe('HydraService', () => { let module: TestingModule; @@ -133,8 +129,7 @@ describe('HydraService', () => { const expectedAuthParams: StatelessAuthorizationParams = { code: 'defaultAuthCode', }; - const axiosConfig: AxiosRequestConfig = { - headers: {}, + const axiosConfig = { withCredentials: true, maxRedirects: 0, validateStatus: jest.fn().mockImplementationOnce(() => true), @@ -145,7 +140,7 @@ describe('HydraService', () => { let responseDto2: HydraRedirectDto; it('should process a local request', async () => { - axiosResponse1 = { + axiosResponse1 = axiosResponseFactory.build({ data: expectedAuthParams, status: 302, statusText: '', @@ -154,7 +149,7 @@ describe('HydraService', () => { Referer: 'hydra', }, config: axiosConfig, - }; + }); responseDto1 = { axiosConfig, cookies: { localCookies: [], hydraCookies: [] }, @@ -169,11 +164,14 @@ describe('HydraService', () => { const resDto: HydraRedirectDto = await service.processRedirect(responseDto1); // Assert - expect(httpService.get).toHaveBeenCalledWith(`${apiHost}${axiosResponse1.headers.location}`, axiosConfig); + expect(httpService.get).toHaveBeenCalledWith( + `${apiHost}${axiosResponse1.headers.location as string}`, + axiosConfig + ); expect(resDto.response.data).toEqual(expectedAuthParams); }); it('should process a hydra request', async () => { - axiosResponse2 = { + axiosResponse2 = axiosResponseFactory.build({ data: expectedAuthParams, status: 200, statusText: '', @@ -182,7 +180,7 @@ describe('HydraService', () => { Referer: 'hydra', }, config: axiosConfig, - }; + }); responseDto2 = { axiosConfig, cookies: { localCookies: [], hydraCookies: [] }, @@ -196,7 +194,7 @@ describe('HydraService', () => { const resDto: HydraRedirectDto = await service.processRedirect(responseDto2); // Assert - expect(httpService.get).toHaveBeenCalledWith(`${axiosResponse2.headers.location}`, axiosConfig); + expect(httpService.get).toHaveBeenCalledWith(`${axiosResponse2.headers.location as string}`, axiosConfig); expect(resDto.response.data).toEqual(expectedAuthParams); }); }); diff --git a/apps/server/src/modules/oauth/service/hydra.service.ts b/apps/server/src/modules/oauth/service/hydra.service.ts index 477b87f4fd4..94ab8c66ff8 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.ts @@ -1,19 +1,19 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HttpService } from '@nestjs/axios'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { OauthConfig } from '@shared/domain'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { LtiToolRepo } from '@shared/repo'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; +import { LtiToolRepo } from '@shared/repo'; +import { LegacyLogger } from '@src/core/logger'; import { AuthorizationParams } from '@src/modules/oauth/controller/dto/authorization.params'; +import { CookiesDto } from '@src/modules/oauth/service/dto/cookies.dto'; +import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import QueryString from 'qs'; -import { HttpService } from '@nestjs/axios'; import { nanoid } from 'nanoid'; -import { firstValueFrom, Observable } from 'rxjs'; -import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; -import { CookiesDto } from '@src/modules/oauth/service/dto/cookies.dto'; -import { DefaultEncryptionService, IEncryptionService } from '@shared/infra/encryption'; -import { LegacyLogger } from '@src/core/logger'; +import QueryString from 'qs'; +import { Observable, firstValueFrom } from 'rxjs'; @Injectable() export class HydraSsoService { @@ -42,7 +42,12 @@ export class HydraSsoService { async processRedirect(dto: HydraRedirectDto): Promise { const localDto: HydraRedirectDto = new HydraRedirectDto(dto); - let { location } = localDto.response.headers; + let location = ''; + + if (typeof localDto.response.headers.location === 'string') { + ({ location } = localDto.response.headers); + } + const isLocal = !location.startsWith('http'); const isHydra = location.startsWith(Configuration.get('HYDRA_PUBLIC_URI') as string); 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 e0518af7987..897ca989c04 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 @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AxiosResponse } from 'axios'; import { of, throwError } from 'rxjs'; import { OAuthSSOError } from '../error/oauth-sso.error'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; @@ -11,15 +11,10 @@ import { OauthAdapterService } from './oauth-adapter.service'; const publicKey = 'publicKey'; -const createAxiosResponse = (data: T): AxiosResponse => { - return { +const createAxiosResponse = (data: T) => + axiosResponseFactory.build({ data, - status: 0, - statusText: '', - headers: {}, - config: {}, - }; -}; + }); jest.mock('jwks-rsa', () => () => { return { 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 8e4df083634..ffcc9abc393 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 @@ -4,11 +4,12 @@ import { HttpModule } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { OauthConfig } from '@shared/domain'; +import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; import { HydraSsoService } from '@src/modules/oauth/service/hydra.service'; import { OAuthService } from '@src/modules/oauth/service/oauth.service'; -import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; @@ -142,7 +143,6 @@ describe('HydraOauthUc', () => { describe('requestAuthCode', () => { let expectedAuthParams: StatelessAuthorizationParams; - let axiosConfig: AxiosRequestConfig; let axiosResponse1: AxiosResponse; let axiosResponse2: AxiosResponse; let responseDto1: HydraRedirectDto; @@ -152,13 +152,12 @@ describe('HydraOauthUc', () => { expectedAuthParams = { code: 'defaultAuthCode', }; - axiosConfig = { - headers: {}, + const axiosConfig = { withCredentials: true, maxRedirects: 0, validateStatus: jest.fn().mockImplementationOnce(() => true), }; - axiosResponse1 = { + axiosResponse1 = axiosResponseFactory.build({ data: expectedAuthParams, status: 302, statusText: '', @@ -167,8 +166,8 @@ describe('HydraOauthUc', () => { Referer: 'hydra', }, config: axiosConfig, - }; - axiosResponse2 = { + }); + axiosResponse2 = axiosResponseFactory.build({ data: expectedAuthParams, status: 200, statusText: '', @@ -177,7 +176,7 @@ describe('HydraOauthUc', () => { Referer: 'hydra', }, config: axiosConfig, - }; + }); responseDto1 = { axiosConfig, cookies: { localCookies: [], hydraCookies: [] }, diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts index 80025f40ed3..ca5fa3a5e1e 100644 --- a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts @@ -114,52 +114,65 @@ describe('OAuthUc', () => { resetAllMocks(); }); - describe('startOauthLogin is called', () => { - const setup = () => { - const systemId = 'systemId'; - 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 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 { - systemId, - system, - 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(); - describe('when starting an oauth login', () => { - it('should return the authentication url for the system', async () => { - const { systemId, system } = setup(); 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(); - it('should save data to the session', async () => { - const { systemId, system } = setup(); const session: DeepMocked = createMock(); const authenticationUrl = 'authenticationUrl'; const postLoginRedirect = 'postLoginRedirect'; @@ -167,6 +180,17 @@ describe('OAuthUc', () => { 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({ @@ -180,8 +204,8 @@ describe('OAuthUc', () => { }); describe('when the system cannot be found', () => { - it('should throw UnprocessableEntityException', async () => { - const { systemId, system } = setup(); + const setup = () => { + const { systemId, system } = createOAuthTestData(); system.oauthConfig = undefined; const session: DeepMocked = createMock(); const authenticationUrl = 'authenticationUrl'; @@ -189,6 +213,16 @@ describe('OAuthUc', () => { 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); @@ -196,7 +230,7 @@ describe('OAuthUc', () => { }); }); - describe('processOAuth is called', () => { + describe('processOAuth', () => { const setup = () => { const postLoginRedirect = 'postLoginRedirect'; const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ @@ -235,6 +269,7 @@ describe('OAuthUc', () => { 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(); @@ -300,7 +335,7 @@ describe('OAuthUc', () => { }); describe('migration', () => { - describe('migrate is called', () => { + describe('migrate', () => { describe('when authorize user and migration was successful', () => { const setupMigration = () => { const code = '43534543jnj543342jn2'; @@ -328,6 +363,7 @@ describe('OAuthUc', () => { jwksEndpoint: 'mock_jwksEndpoint', redirectUri: 'mock_codeRedirectUri', }); + const system: SystemDto = new SystemDto({ id: 'systemId', type: 'oauth', @@ -412,6 +448,7 @@ describe('OAuthUc', () => { jwksEndpoint: 'mock_jwksEndpoint', redirectUri: 'mock_codeRedirectUri', }); + const system: SystemDto = new SystemDto({ id: 'systemId', type: 'oauth', @@ -559,6 +596,7 @@ describe('OAuthUc', () => { oauthService.requestToken.mockResolvedValue(tokenDto); provisioningService.getData.mockResolvedValue(oauthData); oauthService.authenticateUser.mockResolvedValue(tokenDto); + return { query, cachedState, diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.ts b/apps/server/src/modules/oauth/uc/oauth.uc.ts index 9f805ea5209..c153501e338 100644 --- a/apps/server/src/modules/oauth/uc/oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/oauth.uc.ts @@ -8,7 +8,6 @@ import { ICurrentUser } from '@src/modules/authentication'; import { AuthenticationService } from '@src/modules/authentication/services/authentication.service'; import { ProvisioningService } from '@src/modules/provisioning'; import { OauthDataDto } from '@src/modules/provisioning/dto'; -import { SchoolService } from '@src/modules/school'; import { SystemService } from '@src/modules/system'; import { SystemDto } from '@src/modules/system/service/dto/system.dto'; import { UserService } from '@src/modules/user'; @@ -32,7 +31,6 @@ export class OauthUc { private readonly authenticationService: AuthenticationService, private readonly systemService: SystemService, private readonly provisioningService: ProvisioningService, - private readonly schoolService: SchoolService, private readonly userService: UserService, private readonly userMigrationService: UserMigrationService, private readonly schoolMigrationService: SchoolMigrationService, diff --git a/apps/server/src/modules/provisioning/dto/external-group-user.dto.ts b/apps/server/src/modules/provisioning/dto/external-group-user.dto.ts new file mode 100644 index 00000000000..16ed7440319 --- /dev/null +++ b/apps/server/src/modules/provisioning/dto/external-group-user.dto.ts @@ -0,0 +1,12 @@ +import { RoleName } from '@shared/domain'; + +export class ExternalGroupUserDto { + externalUserId: string; + + roleName: RoleName; + + constructor(props: ExternalGroupUserDto) { + this.externalUserId = props.externalUserId; + this.roleName = props.roleName; + } +} diff --git a/apps/server/src/modules/provisioning/dto/external-group.dto.ts b/apps/server/src/modules/provisioning/dto/external-group.dto.ts new file mode 100644 index 00000000000..57cdc78e44c --- /dev/null +++ b/apps/server/src/modules/provisioning/dto/external-group.dto.ts @@ -0,0 +1,28 @@ +import { GroupTypes } from '@src/modules/group'; +import { ExternalGroupUserDto } from './external-group-user.dto'; + +export class ExternalGroupDto { + externalId: string; + + name: string; + + users: ExternalGroupUserDto[]; + + from: Date; + + until: Date; + + type: GroupTypes; + + externalOrganizationId?: string; + + constructor(props: ExternalGroupDto) { + this.externalId = props.externalId; + this.name = props.name; + this.users = props.users; + this.from = props.from; + this.until = props.until; + this.type = props.type; + this.externalOrganizationId = props.externalOrganizationId; + } +} diff --git a/apps/server/src/modules/provisioning/dto/index.ts b/apps/server/src/modules/provisioning/dto/index.ts index 11244218dfc..f88d98aa90f 100644 --- a/apps/server/src/modules/provisioning/dto/index.ts +++ b/apps/server/src/modules/provisioning/dto/index.ts @@ -4,3 +4,5 @@ export * from './provisioning-system.dto'; export * from './external-school.dto'; export * from './external-user.dto'; export * from './oauth-data.dto'; +export * from './external-group.dto'; +export * from './external-group-user.dto'; diff --git a/apps/server/src/modules/provisioning/dto/oauth-data.dto.ts b/apps/server/src/modules/provisioning/dto/oauth-data.dto.ts index e418233ea67..16e09659d46 100644 --- a/apps/server/src/modules/provisioning/dto/oauth-data.dto.ts +++ b/apps/server/src/modules/provisioning/dto/oauth-data.dto.ts @@ -1,6 +1,7 @@ import { ExternalUserDto } from './external-user.dto'; import { ExternalSchoolDto } from './external-school.dto'; import { ProvisioningSystemDto } from './provisioning-system.dto'; +import { ExternalGroupDto } from './external-group.dto'; export class OauthDataDto { system: ProvisioningSystemDto; @@ -9,9 +10,12 @@ export class OauthDataDto { externalSchool?: ExternalSchoolDto; + externalGroups?: ExternalGroupDto[]; + constructor(props: OauthDataDto) { this.system = props.system; this.externalUser = props.externalUser; this.externalSchool = props.externalSchool; + this.externalGroups = props.externalGroups; } } diff --git a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts new file mode 100644 index 00000000000..04b2ad3cba5 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts @@ -0,0 +1,50 @@ +import { SanisGroupRole, SanisSonstigeGruppenzugehoerigeResponse } from '../strategy/sanis/response'; +import { GroupRoleUnknownLoggable } from './group-role-unknown.loggable'; + +describe('GroupRoleUnknownLoggable', () => { + describe('constructor', () => { + const setup = () => { + const sanisSonstigeGruppenzugehoerigeResponse: SanisSonstigeGruppenzugehoerigeResponse = { + ktid: 'ktid', + rollen: [SanisGroupRole.TEACHER], + }; + + return { sanisSonstigeGruppenzugehoerigeResponse }; + }; + + it('should create an instance of UserForGroupNotFoundLoggable', () => { + const { sanisSonstigeGruppenzugehoerigeResponse } = setup(); + + const loggable = new GroupRoleUnknownLoggable(sanisSonstigeGruppenzugehoerigeResponse); + + expect(loggable).toBeInstanceOf(GroupRoleUnknownLoggable); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const sanisSonstigeGruppenzugehoerigeResponse: SanisSonstigeGruppenzugehoerigeResponse = { + ktid: 'ktid', + rollen: [SanisGroupRole.TEACHER], + }; + + const loggable = new GroupRoleUnknownLoggable(sanisSonstigeGruppenzugehoerigeResponse); + + return { loggable, sanisSonstigeGruppenzugehoerigeResponse }; + }; + + it('should return a loggable message', () => { + const { loggable, sanisSonstigeGruppenzugehoerigeResponse } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Unable to add unknown user to group during provisioning.', + data: { + externalUserId: sanisSonstigeGruppenzugehoerigeResponse.ktid, + externalRoleName: sanisSonstigeGruppenzugehoerigeResponse.rollen[0], + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts new file mode 100644 index 00000000000..a146a7011b3 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts @@ -0,0 +1,16 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { SanisSonstigeGruppenzugehoerigeResponse } from '../strategy/sanis/response'; + +export class GroupRoleUnknownLoggable implements Loggable { + constructor(private readonly relation: SanisSonstigeGruppenzugehoerigeResponse) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Unable to add unknown user to group during provisioning.', + data: { + externalUserId: this.relation.ktid, + externalRoleName: this.relation.rollen[0], + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts new file mode 100644 index 00000000000..a790e846f2d --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -0,0 +1,3 @@ +export * from './user-for-group-not-found.loggable'; +export * from './school-for-group-not-found.loggable'; +export * from './group-role-unknown.loggable'; diff --git a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts new file mode 100644 index 00000000000..205c6481529 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts @@ -0,0 +1,45 @@ +import { externalGroupDtoFactory } from '@shared/testing/factory/external-group-dto.factory'; +import { ExternalGroupDto } from '../dto'; +import { SchoolForGroupNotFoundLoggable } from './school-for-group-not-found.loggable'; + +describe('SchoolForGroupNotFoundLoggable', () => { + describe('constructor', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); + + return { externalGroupDto }; + }; + + it('should create an instance of UserForGroupNotFoundLoggable', () => { + const { externalGroupDto } = setup(); + + const loggable = new SchoolForGroupNotFoundLoggable(externalGroupDto); + + expect(loggable).toBeInstanceOf(SchoolForGroupNotFoundLoggable); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); + + const loggable = new SchoolForGroupNotFoundLoggable(externalGroupDto); + + return { loggable, externalGroupDto }; + }; + + it('should return a loggable message', () => { + const { loggable, externalGroupDto } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Unable to provision group, since the connected school cannot be found.', + data: { + externalGroupId: externalGroupDto.externalId, + externalOrganizationId: externalGroupDto.externalOrganizationId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts new file mode 100644 index 00000000000..5fd8dd1f59e --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts @@ -0,0 +1,16 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalGroupDto } from '../dto'; + +export class SchoolForGroupNotFoundLoggable implements Loggable { + constructor(private readonly group: ExternalGroupDto) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Unable to provision group, since the connected school cannot be found.', + data: { + externalGroupId: this.group.externalId, + externalOrganizationId: this.group.externalOrganizationId, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts new file mode 100644 index 00000000000..48728a17081 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts @@ -0,0 +1,51 @@ +import { RoleName } from '@shared/domain'; +import { UserForGroupNotFoundLoggable } from './user-for-group-not-found.loggable'; +import { ExternalGroupUserDto } from '../dto'; + +describe('UserForGroupNotFoundLoggable', () => { + describe('constructor', () => { + const setup = () => { + const externalGroupUserDto: ExternalGroupUserDto = new ExternalGroupUserDto({ + externalUserId: 'externalUserId', + roleName: RoleName.TEACHER, + }); + + return { externalGroupUserDto }; + }; + + it('should create an instance of UserForGroupNotFoundLoggable', () => { + const { externalGroupUserDto } = setup(); + + const loggable = new UserForGroupNotFoundLoggable(externalGroupUserDto); + + expect(loggable).toBeInstanceOf(UserForGroupNotFoundLoggable); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const externalGroupUserDto: ExternalGroupUserDto = new ExternalGroupUserDto({ + externalUserId: 'externalUserId', + roleName: RoleName.TEACHER, + }); + + const loggable = new UserForGroupNotFoundLoggable(externalGroupUserDto); + + return { loggable, externalGroupUserDto }; + }; + + it('should return a loggable message', () => { + const { loggable, externalGroupUserDto } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Unable to add unknown user to group during provisioning.', + data: { + externalUserId: externalGroupUserDto.externalUserId, + roleName: externalGroupUserDto.roleName, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.ts b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.ts new file mode 100644 index 00000000000..1e8b6792969 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.ts @@ -0,0 +1,16 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalGroupUserDto } from '../dto'; + +export class UserForGroupNotFoundLoggable implements Loggable { + constructor(private readonly groupUser: ExternalGroupUserDto) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Unable to add unknown user to group during provisioning.', + data: { + externalUserId: this.groupUser.externalUserId, + roleName: this.groupUser.roleName, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index fc0499c8d46..cc38e3dacf9 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -6,13 +6,14 @@ import { RoleModule } from '@src/modules/role'; import { SchoolModule } from '@src/modules/school/school.module'; import { SystemModule } from '@src/modules/system/system.module'; import { UserModule } from '@src/modules/user'; +import { GroupModule } from '@src/modules/group'; import { ProvisioningService } from './service/provisioning.service'; import { IservProvisioningStrategy, OidcMockProvisioningStrategy, SanisProvisioningStrategy } from './strategy'; import { OidcProvisioningService } from './strategy/oidc/service/oidc-provisioning.service'; import { SanisResponseMapper } from './strategy/sanis/sanis-response.mapper'; @Module({ - imports: [AccountModule, SchoolModule, UserModule, RoleModule, SystemModule, HttpModule, LoggerModule], + imports: [AccountModule, SchoolModule, UserModule, RoleModule, SystemModule, HttpModule, LoggerModule, GroupModule], providers: [ ProvisioningService, SanisResponseMapper, diff --git a/apps/server/src/modules/provisioning/strategy/index.ts b/apps/server/src/modules/provisioning/strategy/index.ts index 6f15e6540ab..369357cc351 100644 --- a/apps/server/src/modules/provisioning/strategy/index.ts +++ b/apps/server/src/modules/provisioning/strategy/index.ts @@ -3,4 +3,3 @@ export * from './iserv/iserv.strategy'; export * from './oidc/oidc.strategy'; export * from './oidc-mock/oidc-mock.strategy'; export * from './sanis/sanis.strategy'; -export * from './sanis/sanis.response'; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts index 85d70550b03..3c1231c3e72 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts @@ -5,7 +5,9 @@ import { RoleName } from '@shared/domain'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { userDoFactory, schoolDOFactory } from '@shared/testing'; +import { schoolDOFactory, userDoFactory } from '@shared/testing'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { externalGroupDtoFactory } from '@shared/testing/factory/external-group-dto.factory'; import { ExternalSchoolDto, ExternalUserDto, @@ -53,47 +55,51 @@ describe('OidcStrategy', () => { await module.close(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe('apply is called', () => { - const setup = () => { - const externalUserId = 'externalUserId'; - const externalSchoolId = 'externalSchoolId'; - const schoolId = 'schoolId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalSchool: new ExternalSchoolDto({ - externalId: externalSchoolId, - name: 'schoolName', - }), - externalUser: new ExternalUserDto({ + describe('when school data is provided', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const schoolId = 'schoolId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: new ExternalSchoolDto({ + externalId: externalSchoolId, + name: 'schoolName', + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + }); + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + schoolId: 'schoolId', externalId: externalUserId, - }), - }); - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - firstName: 'firstName', - lastName: 'lastName', - email: 'email', - schoolId: 'schoolId', - externalId: externalUserId, - }); - const school: SchoolDO = schoolDOFactory.build({ - id: schoolId, - name: 'schoolName', - externalId: externalSchoolId, - }); + }); + const school: SchoolDO = schoolDOFactory.build({ + id: schoolId, + name: 'schoolName', + externalId: externalSchoolId, + }); - oidcProvisioningService.provisionExternalSchool.mockResolvedValue(school); - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); + oidcProvisioningService.provisionExternalSchool.mockResolvedValue(school); + oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); - return { - oauthData, - schoolId, + return { + oauthData, + schoolId, + }; }; - }; - describe('when school data is provided', () => { it('should call the OidcProvisioningService.provisionExternalSchool', async () => { const { oauthData } = setup(); @@ -107,6 +113,45 @@ describe('OidcStrategy', () => { }); describe('when user data is provided', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const schoolId = 'schoolId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: new ExternalSchoolDto({ + externalId: externalSchoolId, + name: 'schoolName', + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + }); + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + schoolId: 'schoolId', + externalId: externalUserId, + }); + const school: SchoolDO = schoolDOFactory.build({ + id: schoolId, + name: 'schoolName', + externalId: externalSchoolId, + }); + + oidcProvisioningService.provisionExternalSchool.mockResolvedValue(school); + oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + schoolId, + }; + }; + it('should call the OidcProvisioningService.provisionExternalUser', async () => { const { oauthData, schoolId } = setup(); @@ -127,5 +172,84 @@ describe('OidcStrategy', () => { expect(result).toEqual(new ProvisioningDto({ externalUserId: oauthData.externalUser.externalId })); }); }); + + describe('when group data is provided and the feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', true); + + const externalUserId = 'externalUserId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups: externalGroupDtoFactory.buildList(2), + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + }; + }; + + it('should call the OidcProvisioningService.provisionExternalGroup for each group', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( + oauthData.externalGroups?.[0], + oauthData.system.systemId + ); + expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( + oauthData.externalGroups?.[1], + oauthData.system.systemId + ); + }); + }); + + describe('when group data is provided, but the feature is disabled', () => { + const setup = () => { + Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', false); + + const externalUserId = 'externalUserId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups: externalGroupDtoFactory.buildList(2), + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + }; + }; + + it('should not call the OidcProvisioningService.provisionExternalGroup', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.provisionExternalGroup).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts index 7fc7b038a68..e52bdb497ee 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { OauthDataDto, ProvisioningDto } from '../../dto'; import { ProvisioningStrategy } from '../base.strategy'; import { OidcProvisioningService } from './service/oidc-provisioning.service'; @@ -22,6 +23,17 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { data.system.systemId, school?.id ); + + if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') && data.externalGroups) { + // TODO: N21-1212 remove user from groups + + await Promise.all( + data.externalGroups.map((externalGroup) => + this.oidcProvisioningService.provisionExternalGroup(externalGroup, data.system.systemId) + ) + ); + } + return new ProvisioningDto({ externalUserId: user.externalId || data.externalUser.externalId }); } } 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 0c452f8b27d..83a7baff0b8 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 @@ -4,16 +4,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RoleName, SchoolFeatures } from '@shared/domain'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { federalStateFactory, schoolDOFactory, userDoFactory } from '@shared/testing'; -import { schoolYearFactory } from '@shared/testing/factory/schoolyear.factory'; +import { + externalGroupDtoFactory, + federalStateFactory, + groupFactory, + roleDtoFactory, + schoolDOFactory, + schoolYearFactory, + userDoFactory, +} from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; import { AccountSaveDto } from '@src/modules/account/services/dto'; +import { Group, GroupService } from '@src/modules/group'; import { RoleService } from '@src/modules/role'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { FederalStateService, SchoolService, SchoolYearService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; import CryptoJS from 'crypto-js'; -import { ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; import { OidcProvisioningService } from './oidc-provisioning.service'; jest.mock('crypto-js'); @@ -28,6 +38,8 @@ describe('OidcProvisioningService', () => { let accountService: DeepMocked; let schoolYearService: DeepMocked; let federalStateService: DeepMocked; + let groupService: DeepMocked; + let logger: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -57,6 +69,14 @@ describe('OidcProvisioningService', () => { provide: FederalStateService, useValue: createMock(), }, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); @@ -67,6 +87,8 @@ describe('OidcProvisioningService', () => { accountService = module.get(AccountService); schoolYearService = module.get(SchoolYearService); federalStateService = module.get(FederalStateService); + groupService = module.get(GroupService); + logger = module.get(Logger); }); afterAll(async () => { @@ -77,7 +99,7 @@ describe('OidcProvisioningService', () => { jest.resetAllMocks(); }); - describe('provisionExternalSchool is called', () => { + describe('provisionExternalSchool', () => { const setup = () => { const systemId = 'systemId'; const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ @@ -178,7 +200,7 @@ describe('OidcProvisioningService', () => { }); }); - describe('provisionExternalUser is called', () => { + describe('provisionExternalUser', () => { const setupUser = () => { const systemId = 'systemId'; const schoolId = 'schoolId'; @@ -319,4 +341,200 @@ describe('OidcProvisioningService', () => { }); }); }); + + describe('provisionExternalGroup', () => { + describe('when the group has no users', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ users: [] }); + + return { + externalGroupDto, + }; + }; + + it('should not create a group', async () => { + const { externalGroupDto } = setup(); + + await service.provisionExternalGroup(externalGroupDto, 'systemId'); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + }); + + describe('when group does not have an externalOrganizationId', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ externalOrganizationId: undefined }); + + return { + externalGroupDto, + }; + }; + + it('should not call schoolService.getSchoolByExternalId', async () => { + const { externalGroupDto } = setup(); + + await service.provisionExternalGroup(externalGroupDto, 'systemId'); + + expect(schoolService.getSchoolByExternalId).not.toHaveBeenCalled(); + }); + }); + + describe('when school for group could not be found', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ externalOrganizationId: 'orgaId' }); + const systemId = 'systemId'; + schoolService.getSchoolByExternalId.mockResolvedValueOnce(null); + + return { + externalGroupDto, + systemId, + }; + }; + + it('should log a SchoolForGroupNotFoundLoggable', async () => { + const { externalGroupDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, systemId); + + expect(logger.info).toHaveBeenCalledWith(new SchoolForGroupNotFoundLoggable(externalGroupDto)); + }); + + it('should not call groupService.save', async () => { + const { externalGroupDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + }); + + describe('when externalGroup has no users', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ + users: [], + }); + + return { + externalGroupDto, + }; + }; + + it('should not call userService.findByExternalId', async () => { + const { externalGroupDto } = setup(); + + await service.provisionExternalGroup(externalGroupDto, 'systemId'); + + expect(userService.findByExternalId).not.toHaveBeenCalled(); + }); + + it('should not call roleService.findByNames', async () => { + const { externalGroupDto } = setup(); + + await service.provisionExternalGroup(externalGroupDto, 'systemId'); + + expect(roleService.findByNames).not.toHaveBeenCalled(); + }); + }); + + describe('when externalGroupUser could not been found', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); + const systemId = 'systemId'; + const school: SchoolDO = schoolDOFactory.buildWithId(); + + userService.findByExternalId.mockResolvedValue(null); + schoolService.getSchoolByExternalId.mockResolvedValue(school); + + return { + externalGroupDto, + systemId, + }; + }; + + it('should log a UserForGroupNotFoundLoggable', async () => { + const { externalGroupDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, systemId); + + expect(logger.info).toHaveBeenCalledWith(new UserForGroupNotFoundLoggable(externalGroupDto.users[0])); + }); + }); + + describe('when provision group', () => { + const setup = () => { + const group: Group = groupFactory.build(); + groupService.findByExternalSource.mockResolvedValue(group); + + const school: SchoolDO = schoolDOFactory.build({ id: 'schoolId' }); + schoolService.getSchoolByExternalId.mockResolvedValue(school); + + const student: UserDO = userDoFactory + .withRoles([{ id: 'studentRoleId', name: RoleName.STUDENT }]) + .build({ id: 'studentId', externalId: 'studentExternalId' }); + const teacher: UserDO = userDoFactory + .withRoles([{ id: 'teacherRoleId', name: RoleName.TEACHER }]) + .build({ id: 'teacherId', externalId: 'teacherExternalId' }); + userService.findByExternalId.mockResolvedValueOnce(student); + userService.findByExternalId.mockResolvedValueOnce(teacher); + const studentRole: RoleDto = roleDtoFactory.build({ name: RoleName.STUDENT }); + const teacherRole: RoleDto = roleDtoFactory.build({ name: RoleName.TEACHER }); + roleService.findByNames.mockResolvedValueOnce([studentRole]); + roleService.findByNames.mockResolvedValueOnce([teacherRole]); + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ + users: [ + { + externalUserId: student.externalId as string, + roleName: RoleName.STUDENT, + }, + { + externalUserId: teacher.externalId as string, + roleName: RoleName.TEACHER, + }, + ], + }); + const systemId = 'systemId'; + + return { + externalGroupDto, + school, + student, + teacher, + studentRole, + teacherRole, + systemId, + }; + }; + + it('should save a new group', async () => { + const { externalGroupDto, school, student, studentRole, teacher, teacherRole, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, systemId); + + expect(groupService.save).toHaveBeenCalledWith({ + props: { + id: expect.any(String), + name: externalGroupDto.name, + externalSource: { + externalId: externalGroupDto.externalId, + systemId, + }, + type: externalGroupDto.type, + organizationId: school.id, + validFrom: externalGroupDto.from, + validUntil: externalGroupDto.until, + users: [ + { + userId: student.id, + roleId: studentRole.id, + }, + { + userId: teacher.id, + roleId: teacherRole.id, + }, + ], + }, + }); + }); + }); + }); }); 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 0172d36d27e..cf07810498a 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,27 +1,33 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, FederalState, SchoolFeatures, SchoolYear } from '@shared/domain'; +import { EntityId, ExternalSource, FederalState, SchoolFeatures, SchoolYear } from '@shared/domain'; import { RoleReference } from '@shared/domain/domainobject'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; +import { Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; import { AccountSaveDto } from '@src/modules/account/services/dto'; +import { Group, GroupService, GroupUser } from '@src/modules/group'; import { RoleService } from '@src/modules/role'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { FederalStateService, SchoolService, SchoolYearService } from '@src/modules/school'; import { FederalStateNames } from '@src/modules/school/types'; import { UserService } from '@src/modules/user'; +import { ObjectId } from 'bson'; import CryptoJS from 'crypto-js'; -import { ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @Injectable() export class OidcProvisioningService { constructor( private readonly userService: UserService, private readonly schoolService: SchoolService, + private readonly groupService: GroupService, private readonly roleService: RoleService, private readonly accountService: AccountService, private readonly schoolYearService: SchoolYearService, - private readonly federalStateService: FederalStateService + private readonly federalStateService: FederalStateService, + private readonly logger: Logger ) {} async provisionExternalSchool(externalSchool: ExternalSchoolDto, systemId: EntityId): Promise { @@ -112,4 +118,73 @@ export class OidcProvisioningService { return savedUser; } + + async provisionExternalGroup(externalGroup: ExternalGroupDto, systemId: EntityId): Promise { + if (externalGroup.users.length === 0) { + return; + } + + const existingGroup: Group | null = await this.groupService.findByExternalSource( + externalGroup.externalId, + systemId + ); + + let organizationId: string | undefined; + if (externalGroup.externalOrganizationId) { + const existingSchool: SchoolDO | null = await this.schoolService.getSchoolByExternalId( + externalGroup.externalOrganizationId, + systemId + ); + + if (!existingSchool || !existingSchool.id) { + this.logger.info(new SchoolForGroupNotFoundLoggable(externalGroup)); + return; + } + + organizationId = existingSchool.id; + } + + const users: GroupUser[] = await this.getFilteredGroupUsers(externalGroup, systemId); + + const group: Group = new Group({ + id: existingGroup ? existingGroup.id : new ObjectId().toHexString(), + name: externalGroup.name, + externalSource: new ExternalSource({ + externalId: externalGroup.externalId, + systemId, + }), + type: externalGroup.type, + organizationId, + validFrom: externalGroup.from, + validUntil: externalGroup.until, + users, + }); + + await this.groupService.save(group); + } + + private async getFilteredGroupUsers(externalGroup: ExternalGroupDto, systemId: string): Promise { + const users: (GroupUser | null)[] = await Promise.all( + externalGroup.users.map(async (externalGroupUser: ExternalGroupUserDto): Promise => { + const user: UserDO | null = await this.userService.findByExternalId(externalGroupUser.externalUserId, systemId); + const roles: RoleDto[] = await this.roleService.findByNames([externalGroupUser.roleName]); + + if (!user || !user.id || roles.length !== 1 || !roles[0].id) { + this.logger.info(new UserForGroupNotFoundLoggable(externalGroupUser)); + return null; + } + + const groupUser: GroupUser = new GroupUser({ + userId: user.id, + roleId: roles[0].id, + }); + + return groupUser; + }) + ); + + const filteredUsers: GroupUser[] = users.filter((groupUser): groupUser is GroupUser => groupUser !== null); + + return filteredUsers; + } } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts new file mode 100644 index 00000000000..56f70ad0f41 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts @@ -0,0 +1,13 @@ +export * from './sanis.response'; +export * from './sanis-role'; +export * from './sanis-group-role'; +export * from './sanis-group-type'; +export * from './sanis-name-response'; +export * from './sanis-gruppe-response'; +export * from './sanis-gruppen-response'; +export * from './sanis-laufzeit-response'; +export * from './sanis-organisation-response'; +export * from './sanis-personenkontext-response'; +export * from './sanis-gruppenzugehoerigkeit-response'; +export * from './sanis-person-response'; +export * from './sanis-sonstige-gruppenzugehoerige-response'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-role.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-role.ts new file mode 100644 index 00000000000..358333ffe56 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-role.ts @@ -0,0 +1,9 @@ +export enum SanisGroupRole { + TEACHER = 'Lehr', + STUDENT = 'Lern', + CLASS_LEADER = 'KlLeit', + SUPPORT_TEACHER = 'Foerd', + SCHOOL_SUPPORT = 'SchB', + GROUP_MEMBER = 'GMit', + GROUP_LEADER = 'GLeit', +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-type.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-type.ts new file mode 100644 index 00000000000..1af634d511b --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-type.ts @@ -0,0 +1,5 @@ +export enum SanisGroupType { + CLASS = 'Klasse', + COURSE = 'Kurs', + OTHER = 'Sonstig', +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts new file mode 100644 index 00000000000..93d8af0e884 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts @@ -0,0 +1,14 @@ +import { SanisGroupType } from './sanis-group-type'; +import { SanisLaufzeitResponse } from './sanis-laufzeit-response'; + +export interface SanisGruppeResponse { + id: string; + + bezeichnung: string; + + typ: SanisGroupType; + + orgid: string; + + laufzeit: SanisLaufzeitResponse; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts new file mode 100644 index 00000000000..8154676b8f2 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts @@ -0,0 +1,11 @@ +import { SanisGruppeResponse } from './sanis-gruppe-response'; +import { SanisGruppenzugehoerigkeitResponse } from './sanis-gruppenzugehoerigkeit-response'; +import { SanisSonstigeGruppenzugehoerigeResponse } from './sanis-sonstige-gruppenzugehoerige-response'; + +export interface SanisGruppenResponse { + gruppe: SanisGruppeResponse; + + gruppenzugehoerigkeit: SanisGruppenzugehoerigkeitResponse; + + sonstige_gruppenzugehoerige?: SanisSonstigeGruppenzugehoerigeResponse[]; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts new file mode 100644 index 00000000000..16098edd6bf --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts @@ -0,0 +1,5 @@ +import { SanisGroupRole } from './sanis-group-role'; + +export interface SanisGruppenzugehoerigkeitResponse { + rollen: SanisGroupRole[]; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts new file mode 100644 index 00000000000..ad5ac800cdf --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts @@ -0,0 +1,5 @@ +export interface SanisLaufzeitResponse { + von: Date; + + bis: Date; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts new file mode 100644 index 00000000000..fad5c23319a --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts @@ -0,0 +1,5 @@ +export interface SanisNameResponse { + familienname: string; + + vorname: 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 new file mode 100644 index 00000000000..258cde00a50 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts @@ -0,0 +1,9 @@ +export interface SanisOrganisationResponse { + id: string; + + kennung: string; + + name: string; + + typ: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts new file mode 100644 index 00000000000..e34d324b2e7 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts @@ -0,0 +1,11 @@ +import { SanisNameResponse } from './sanis-name-response'; + +export interface SanisPersonResponse { + name: SanisNameResponse; + + geschlecht: string; + + lokalisierung: string; + + vertrauensstufe: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts new file mode 100644 index 00000000000..c7d1252b2da --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts @@ -0,0 +1,15 @@ +import { SanisRole } from './sanis-role'; +import { SanisGruppenResponse } from './sanis-gruppen-response'; +import { SanisOrganisationResponse } from './sanis-organisation-response'; + +export interface SanisPersonenkontextResponse { + id: string; + + rolle: SanisRole; + + organisation: SanisOrganisationResponse; + + personenstatus: string; + + gruppen?: SanisGruppenResponse[]; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-role.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-role.ts new file mode 100644 index 00000000000..bd3c71f0826 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-role.ts @@ -0,0 +1,6 @@ +export enum SanisRole { + LEHR = 'Lehr', + LERN = 'Lern', + LEIT = 'Leit', + ORGADMIN = 'OrgAdmin', +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts new file mode 100644 index 00000000000..0aa20be24dc --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts @@ -0,0 +1,6 @@ +import { SanisGroupRole } from './sanis-group-role'; + +export interface SanisSonstigeGruppenzugehoerigeResponse { + ktid: string; + rollen: SanisGroupRole[]; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts new file mode 100644 index 00000000000..fb8bb8ec6c8 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts @@ -0,0 +1,10 @@ +import { SanisPersonResponse } from './sanis-person-response'; +import { SanisPersonenkontextResponse } from './sanis-personenkontext-response'; + +export interface SanisResponse { + pid: string; + + person: SanisPersonResponse; + + personenkontexte: SanisPersonenkontextResponse[]; +} 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 e931fd700af..d199bfce8e6 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,51 +1,89 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { GroupTypes } from '@src/modules/group'; import { UUID } from 'bson'; -import { ExternalSchoolDto, ExternalUserDto } from '../../dto'; -import { SanisResponseMapper } from './sanis-response.mapper'; +import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { + SanisGroupRole, + SanisGroupType, + SanisGruppenResponse, + SanisPersonenkontextResponse, SanisResponse, - SanisResponseName, - SanisResponseOrganisation, - SanisResponsePersonenkontext, SanisRole, -} from './sanis.response'; +} from './response'; +import { SanisResponseMapper } from './sanis-response.mapper'; describe('SanisResponseMapper', () => { + let module: TestingModule; let mapper: SanisResponseMapper; - beforeAll(() => { - mapper = new SanisResponseMapper(); + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SanisResponseMapper, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + mapper = module.get(SanisResponseMapper); }); const setupSanisResponse = () => { const externalUserId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; const externalSchoolId = 'df66c8e6-cfac-40f7-b35b-0da5d8ee680e'; - const sanisResponse: SanisResponse = new SanisResponse({ + const sanisResponse: SanisResponse = { pid: externalUserId, person: { - name: new SanisResponseName({ + name: { vorname: 'firstName', familienname: 'lastName', - }), + }, geschlecht: 'x', lokalisierung: 'de-de', vertrauensstufe: '', }, personenkontexte: [ - new SanisResponsePersonenkontext({ - id: new UUID(), + { + id: new UUID().toString(), rolle: SanisRole.LERN, - organisation: new SanisResponseOrganisation({ - id: new UUID(externalSchoolId), + organisation: { + id: new UUID(externalSchoolId).toString(), name: 'schoolName', typ: 'SCHULE', kennung: 'NI_123456_NI_ashd3838', - }), + }, personenstatus: '', - email: 'test@te.st', - }), + gruppen: [ + { + gruppe: { + id: new UUID().toString(), + bezeichnung: 'bezeichnung', + typ: SanisGroupType.CLASS, + laufzeit: { + von: new Date(2023, 1, 8), + bis: new Date(2024, 7, 31), + }, + orgid: 'orgid', + }, + gruppenzugehoerigkeit: { + rollen: [SanisGroupRole.TEACHER], + }, + sonstige_gruppenzugehoerige: [ + { + rollen: [SanisGroupRole.STUDENT], + ktid: 'ktid', + }, + ], + }, + ], + }, ], - }); + }; return { externalUserId, @@ -54,7 +92,7 @@ describe('SanisResponseMapper', () => { }; }; - describe('mapToExternalSchoolDto is called', () => { + describe('mapToExternalSchoolDto', () => { describe('when a sanis response is provided', () => { it('should map the response to an ExternalSchoolDto', () => { const { sanisResponse, externalSchoolId } = setupSanisResponse(); @@ -70,7 +108,7 @@ describe('SanisResponseMapper', () => { }); }); - describe('mapToExternalUserDto is called', () => { + describe('mapToExternalUserDto', () => { describe('when a sanis response is provided', () => { it('should map the response to an ExternalUserDto', () => { const { sanisResponse, externalUserId } = setupSanisResponse(); @@ -81,10 +119,128 @@ describe('SanisResponseMapper', () => { externalId: externalUserId, firstName: 'firstName', lastName: 'lastName', - email: 'test@te.st', roles: [RoleName.STUDENT], }); }); }); }); + + describe('mapToExternalGroupDtos', () => { + describe('when no group is given', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen = undefined; + + return { + sanisResponse, + }; + }; + + it('should return undefined', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result).toBeUndefined(); + }); + }); + + describe('when group type is given', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + const personenkontext: SanisPersonenkontextResponse = sanisResponse.personenkontexte[0]; + const group: SanisGruppenResponse = personenkontext.gruppen![0]; + + return { + sanisResponse, + group, + personenkontext, + }; + }; + + it('should map the sanis response to external group dtos', () => { + const { sanisResponse, group, personenkontext } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result![0]).toEqual({ + name: group.gruppe.bezeichnung, + type: GroupTypes.CLASS, + externalOrganizationId: group.gruppe.orgid, + from: group.gruppe.laufzeit.von, + until: group.gruppe.laufzeit.bis, + externalId: group.gruppe.id, + users: [ + { + externalUserId: group.sonstige_gruppenzugehoerige![0].ktid, + roleName: RoleName.STUDENT, + }, + { + externalUserId: personenkontext.id, + roleName: RoleName.TEACHER, + }, + ].sort((a, b) => a.externalUserId.localeCompare(b.externalUserId)), + }); + }); + }); + + describe('when no group type is provided', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen![0]!.gruppe.typ = SanisGroupType.OTHER; + + return { + sanisResponse, + }; + }; + + it('should return empty array', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result).toHaveLength(0); + }); + }); + + describe('when a group role mapping is missing', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige![0].rollen = [ + SanisGroupRole.SCHOOL_SUPPORT, + ]; + + return { + sanisResponse, + }; + }; + + it('should return only users with known roles', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result![0].users).toHaveLength(1); + }); + }); + + describe('when a group has no other participants', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = undefined; + + return { + sanisResponse, + }; + }; + + it('should return the group with only the user', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result![0].users).toHaveLength(1); + }); + }); + }); }); 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 037ed40e4a6..3a0d340427e 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,42 +1,125 @@ import { Injectable } from '@nestjs/common'; import { RoleName } from '@shared/domain'; -import { ExternalSchoolDto, ExternalUserDto } from '../../dto'; -import { SanisResponse, SanisRole } from './sanis.response'; +import { Logger } from '@src/core/logger'; +import { GroupTypes } from '@src/modules/group'; +import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; +import { GroupRoleUnknownLoggable } from '../../loggable'; +import { + SanisGroupRole, + SanisGroupType, + SanisGruppenResponse, + SanisResponse, + SanisRole, + SanisSonstigeGruppenzugehoerigeResponse, +} from './response'; -const RoleMapping = { +const RoleMapping: Record = { [SanisRole.LEHR]: RoleName.TEACHER, [SanisRole.LERN]: RoleName.STUDENT, [SanisRole.LEIT]: RoleName.ADMINISTRATOR, [SanisRole.ORGADMIN]: RoleName.ADMINISTRATOR, }; +const GroupRoleMapping: Partial> = { + [SanisGroupRole.TEACHER]: RoleName.TEACHER, + [SanisGroupRole.STUDENT]: RoleName.STUDENT, +}; + +const GroupTypeMapping: Partial> = { + [SanisGroupType.CLASS]: GroupTypes.CLASS, +}; + @Injectable() export class SanisResponseMapper { SCHOOLNUMBER_PREFIX_REGEX = /^NI_/; + constructor(private readonly logger: Logger) {} + mapToExternalSchoolDto(source: SanisResponse): ExternalSchoolDto { const officialSchoolNumber: string = source.personenkontexte[0].organisation.kennung.replace( this.SCHOOLNUMBER_PREFIX_REGEX, '' ); - return new ExternalSchoolDto({ + + const mapped = new ExternalSchoolDto({ name: source.personenkontexte[0].organisation.name, externalId: source.personenkontexte[0].organisation.id.toString(), officialSchoolNumber, }); + + return mapped; } mapToExternalUserDto(source: SanisResponse): ExternalUserDto { - return new ExternalUserDto({ + const mapped = new ExternalUserDto({ firstName: source.person.name.vorname, lastName: source.person.name.familienname, - email: source.personenkontexte[0].email, roles: [this.mapSanisRoleToRoleName(source)], externalId: source.pid, }); + + return mapped; } private mapSanisRoleToRoleName(source: SanisResponse): RoleName { return RoleMapping[source.personenkontexte[0].rolle]; } + + mapToExternalGroupDtos(source: SanisResponse): ExternalGroupDto[] | undefined { + const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0].gruppen; + + if (!groups) { + return undefined; + } + + const mapped: ExternalGroupDto[] = groups + .map((group): ExternalGroupDto | null => { + const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; + + if (!groupType) { + return null; + } + + const sanisGroupUsers: SanisSonstigeGruppenzugehoerigeResponse[] = [ + ...(group.sonstige_gruppenzugehoerige ?? []), + { + ktid: source.personenkontexte[0].id, + rollen: group.gruppenzugehoerigkeit.rollen, + }, + ].sort((a, b) => a.ktid.localeCompare(b.ktid)); + + const gruppenzugehoerigkeiten: ExternalGroupUserDto[] = sanisGroupUsers + .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) + .filter((user): user is ExternalGroupUserDto => user !== null); + + return { + name: group.gruppe.bezeichnung, + type: groupType, + externalOrganizationId: group.gruppe.orgid, + from: group.gruppe.laufzeit?.von, + until: group.gruppe.laufzeit?.bis, + externalId: group.gruppe.id, + users: gruppenzugehoerigkeiten, + }; + }) + .filter((group): group is ExternalGroupDto => group !== null); + + return mapped; + } + + private mapToExternalGroupUser(relation: SanisSonstigeGruppenzugehoerigeResponse): ExternalGroupUserDto | null { + const userRole = GroupRoleMapping[relation.rollen[0]]; + + if (!userRole) { + this.logger.info(new GroupRoleUnknownLoggable(relation)); + return null; + } + + const mapped = new ExternalGroupUserDto({ + roleName: userRole, + externalUserId: relation.ktid, + }); + + return mapped; + } } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.response.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.response.ts deleted file mode 100644 index 305d931eabe..00000000000 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.response.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { UUID } from 'bson'; - -export enum SanisRole { - LEHR = 'Lehr', - LERN = 'Lern', - LEIT = 'Leit', - ORGADMIN = 'OrgAdmin', -} - -export class SanisResponseName { - constructor(sanisResponseName: SanisResponseName) { - this.familienname = sanisResponseName.familienname; - this.vorname = sanisResponseName.vorname; - } - - familienname: string; - - vorname: string; -} - -export class SanisResponsePersonenkontext { - constructor(sanisResponsePersonenkontext: SanisResponsePersonenkontext) { - this.id = sanisResponsePersonenkontext.id; - this.rolle = sanisResponsePersonenkontext.rolle; - this.organisation = sanisResponsePersonenkontext.organisation; - this.personenstatus = sanisResponsePersonenkontext.personenstatus; - this.email = sanisResponsePersonenkontext.email; - } - - id: UUID; - - rolle: SanisRole; - - organisation: SanisResponseOrganisation; - - personenstatus: string; - - // TODO change if neccessary once we have the proper specification - email: string; -} - -export class SanisResponseOrganisation { - constructor(sanisResponseOrganisation: SanisResponseOrganisation) { - this.id = sanisResponseOrganisation.id; - this.kennung = sanisResponseOrganisation.kennung; - this.name = sanisResponseOrganisation.name; - this.typ = sanisResponseOrganisation.typ; - } - - id: UUID; - - kennung: string; - - name: string; - - typ: string; -} - -export class SanisResponse { - constructor(sanisResponse: SanisResponse) { - this.pid = sanisResponse.pid; - this.person = sanisResponse.person; - this.personenkontexte = sanisResponse.personenkontexte; - } - - pid: string; - - person: { - name: SanisResponseName; - geschlecht: string; - lokalisierung: string; - vertrauensstufe: string; - }; - - personenkontexte: SanisResponsePersonenkontext[]; -} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts index e4b512f1029..b1324cbf74b 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts @@ -1,14 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +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 { RoleName } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { setupEntities } from '@shared/testing'; -import { AxiosResponse } from 'axios'; +import { axiosResponseFactory, setupEntities } from '@shared/testing'; +import { GroupTypes } from '@src/modules/group'; import { UUID } from 'bson'; import { of } from 'rxjs'; -import { RoleName } from '@shared/domain'; import { + ExternalGroupDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, @@ -16,25 +18,14 @@ import { ProvisioningSystemDto, } from '../../dto'; import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; +import { SanisGroupRole, SanisGroupType, SanisGruppenResponse, SanisResponse, SanisRole } from './response'; import { SanisResponseMapper } from './sanis-response.mapper'; -import { - SanisResponse, - SanisResponseName, - SanisResponseOrganisation, - SanisResponsePersonenkontext, - SanisRole, -} from './sanis.response'; import { SanisProvisioningStrategy } from './sanis.strategy'; -const createAxiosResponse = (data: SanisResponse): AxiosResponse => { - return { - data: data ?? {}, - status: 0, - statusText: '', - headers: {}, - config: {}, - }; -}; +const createAxiosResponse = (data: SanisResponse) => + axiosResponseFactory.build({ + data, + }); describe('SanisStrategy', () => { let module: TestingModule; @@ -71,8 +62,60 @@ describe('SanisStrategy', () => { afterEach(() => { jest.resetAllMocks(); + Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'true'); }); + const setupSanisResponse = () => { + return { + pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', + person: { + name: { + vorname: 'Hans', + familienname: 'Peter', + }, + geschlecht: 'any', + lokalisierung: 'sn_ZW', + vertrauensstufe: '0', + }, + personenkontexte: [ + { + id: new UUID().toString(), + rolle: SanisRole.LEIT, + organisation: { + id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(), + name: 'schoolName', + typ: 'SCHULE', + kennung: 'Kennung', + }, + personenstatus: 'dead', + gruppen: [ + { + gruppe: { + id: new UUID().toString(), + bezeichnung: 'bezeichnung', + typ: SanisGroupType.CLASS, + laufzeit: { + von: new Date(2023, 1, 8), + bis: new Date(2024, 7, 31), + }, + orgid: 'orgid', + }, + gruppenzugehoerigkeit: { + rollen: [SanisGroupRole.TEACHER], + }, + sonstige_gruppenzugehoerige: [ + { + rollen: [SanisGroupRole.STUDENT], + ktid: 'ktid', + }, + ], + }, + ], + }, + ], + }; + }; + describe('getType is called', () => { describe('when it is called', () => { it('should return type SANIS', () => { @@ -84,64 +127,58 @@ describe('SanisStrategy', () => { }); describe('getData is called', () => { - const setup = () => { - const provisioningUrl = 'sanisProvisioningUrl'; - const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - provisioningUrl, - }), - idToken: 'sanisIdToken', - accessToken: 'sanisAccessToken', - }); - const sanisResponse: SanisResponse = new SanisResponse({ - pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', - person: { - name: new SanisResponseName({ - vorname: 'Hans', - familienname: 'Peter', + describe('when fetching data from sanis', () => { + const setup = () => { + const provisioningUrl = 'sanisProvisioningUrl'; + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl, }), - geschlecht: 'any', - lokalisierung: 'sn_ZW', - vertrauensstufe: '0', - }, - personenkontexte: [ - new SanisResponsePersonenkontext({ - id: new UUID(), - rolle: SanisRole.LEIT, - organisation: new SanisResponseOrganisation({ - id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e'), - name: 'schoolName', - typ: 'SCHULE', - kennung: 'Kennung', - }), - personenstatus: 'dead', - email: 'test@te.st', + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse: SanisResponse = setupSanisResponse(); + const user: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + }); + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + name: 'schoolName', + }); + const sanisGruppeResponse: SanisGruppenResponse = sanisResponse.personenkontexte[0].gruppen![0]!; + const groups: ExternalGroupDto[] = [ + new ExternalGroupDto({ + name: sanisGruppeResponse.gruppe.bezeichnung, + externalId: sanisGruppeResponse.gruppe.id, + type: GroupTypes.CLASS, + externalOrganizationId: sanisGruppeResponse.gruppe.orgid, + from: sanisGruppeResponse.gruppe.laufzeit.von, + until: sanisGruppeResponse.gruppe.laufzeit.bis, + users: [ + { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, + ], }), - ], - }); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); - const school: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalSchoolId', - name: 'schoolName', - }); + ]; - httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); - mapper.mapToExternalUserDto.mockReturnValue(user); - mapper.mapToExternalSchoolDto.mockReturnValue(school); + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); + mapper.mapToExternalUserDto.mockReturnValue(user); + mapper.mapToExternalSchoolDto.mockReturnValue(school); + mapper.mapToExternalGroupDtos.mockReturnValue(groups); - return { - input, - provisioningUrl, - user, - school, + return { + input, + provisioningUrl, + user, + school, + groups, + }; }; - }; - describe('when fetching data from sanis', () => { it('should make a Http-GET-Request to the provisioning url of sanis with an access token', async () => { const { input, provisioningUrl } = setup(); @@ -157,7 +194,7 @@ describe('SanisStrategy', () => { }); it('should return the oauth data', async () => { - const { input, user, school } = setup(); + const { input, user, school, groups } = setup(); const result: OauthDataDto = await strategy.getData(input); @@ -165,14 +202,101 @@ describe('SanisStrategy', () => { system: input.system, externalUser: user, externalSchool: school, + externalGroups: groups, }); }); }); + describe('when FEATURE_SANIS_GROUP_PROVISIONING_ENABLED is false', () => { + const setup = () => { + const provisioningUrl = 'sanisProvisioningUrl'; + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl, + }), + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse: SanisResponse = setupSanisResponse(); + const user: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + }); + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + name: 'schoolName', + }); + + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); + mapper.mapToExternalUserDto.mockReturnValue(user); + mapper.mapToExternalSchoolDto.mockReturnValue(school); + + Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'false'); + + return { + input, + }; + }; + + it('should not call mapToExternalGroupDtos', async () => { + const { input } = setup(); + + await strategy.getData(input); + + expect(mapper.mapToExternalGroupDtos).not.toHaveBeenCalled(); + }); + }); + describe('when the provided system has no provisioning url', () => { + const setup = () => { + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: undefined, + }), + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse: SanisResponse = setupSanisResponse(); + const user: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + }); + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + name: 'schoolName', + }); + const sanisGruppeResponse: SanisGruppenResponse = sanisResponse.personenkontexte[0].gruppen![0]!; + const groups: ExternalGroupDto[] = [ + new ExternalGroupDto({ + name: sanisGruppeResponse.gruppe.bezeichnung, + externalId: sanisGruppeResponse.gruppe.id, + type: GroupTypes.CLASS, + externalOrganizationId: sanisGruppeResponse.gruppe.orgid, + from: sanisGruppeResponse.gruppe.laufzeit.von, + until: sanisGruppeResponse.gruppe.laufzeit.bis, + users: [ + { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, + ], + }), + ]; + + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); + mapper.mapToExternalUserDto.mockReturnValue(user); + mapper.mapToExternalSchoolDto.mockReturnValue(school); + mapper.mapToExternalGroupDtos.mockReturnValue(groups); + + return { + input, + }; + }; + it('should throw an InternalServerErrorException', async () => { const { input } = setup(); - input.system.provisioningUrl = undefined; const promise: Promise = strategy.getData(input); @@ -181,13 +305,56 @@ describe('SanisStrategy', () => { }); describe('when role from sanis is admin', () => { - it('should add teacher and admin svs role to externalUser', async () => { - const { input } = setup(); + const setup = () => { + const provisioningUrl = 'sanisProvisioningUrl'; + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl, + }), + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse: SanisResponse = setupSanisResponse(); const user = new ExternalUserDto({ externalId: 'externalSchoolId', roles: [RoleName.ADMINISTRATOR], }); + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + name: 'schoolName', + }); + const sanisGruppeResponse: SanisGruppenResponse = sanisResponse.personenkontexte[0].gruppen![0]!; + const groups: ExternalGroupDto[] = [ + new ExternalGroupDto({ + name: sanisGruppeResponse.gruppe.bezeichnung, + externalId: sanisGruppeResponse.gruppe.id, + type: GroupTypes.CLASS, + externalOrganizationId: sanisGruppeResponse.gruppe.orgid, + from: sanisGruppeResponse.gruppe.laufzeit.von, + until: sanisGruppeResponse.gruppe.laufzeit.bis, + users: [ + { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, + ], + }), + ]; + + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); mapper.mapToExternalUserDto.mockReturnValue(user); + mapper.mapToExternalSchoolDto.mockReturnValue(school); + mapper.mapToExternalGroupDtos.mockReturnValue(groups); + + return { + input, + }; + }; + + it('should add teacher and admin svs role to externalUser', async () => { + const { input } = setup(); const result: OauthDataDto = await strategy.getData(input); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts index fd0a50879ac..a09ae204c69 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts @@ -1,14 +1,21 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { firstValueFrom } from 'rxjs'; import { RoleName } from '@shared/domain'; -import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto } from '../../dto'; +import { + ExternalSchoolDto, + ExternalUserDto, + OauthDataDto, + OauthDataStrategyInputDto, + ExternalGroupDto, +} from '../../dto'; import { OidcProvisioningStrategy } from '../oidc/oidc.strategy'; import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; import { SanisResponseMapper } from './sanis-response.mapper'; -import { SanisResponse } from './sanis.response'; +import { SanisResponse } from './response'; @Injectable() export class SanisProvisioningStrategy extends OidcProvisioningStrategy { @@ -44,11 +51,18 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { const externalSchool: ExternalSchoolDto = this.responseMapper.mapToExternalSchoolDto(axiosResponse.data); + let externalGroups: ExternalGroupDto[] | undefined; + if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { + externalGroups = this.responseMapper.mapToExternalGroupDtos(axiosResponse.data); + } + const oauthData: OauthDataDto = new OauthDataDto({ system: input.system, externalSchool, externalUser, + externalGroups, }); + return oauthData; } diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.entity.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.entity.ts index 6cabaffd0ee..c9c051fa8b3 100644 --- a/apps/server/src/modules/pseudonym/entity/pseudonym.entity.ts +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.entity.ts @@ -1,9 +1,9 @@ import { Entity, 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'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -export interface IPseudonymEntityProps { +export interface PseudonymEntityProps { id?: EntityId; pseudonym: string; toolId: ObjectId; @@ -23,7 +23,7 @@ export class PseudonymEntity extends BaseEntityWithTimestamps { @Property() userId: ObjectId; - constructor(props: IPseudonymEntityProps) { + constructor(props: PseudonymEntityProps) { super(); if (props.id != null) { this.id = props.id; diff --git a/apps/server/src/modules/pseudonym/pseudonym.module.ts b/apps/server/src/modules/pseudonym/pseudonym.module.ts index 22f75f0e570..01e649d8374 100644 --- a/apps/server/src/modules/pseudonym/pseudonym.module.ts +++ b/apps/server/src/modules/pseudonym/pseudonym.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; -import { PseudonymService } from './service'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from './repo'; -import { ToolConfigModule } from '../tool/tool-config.module'; +import { PseudonymService } from './service'; @Module({ - imports: [ToolConfigModule], providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger], exports: [PseudonymService], }) diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts index 8d7dce27338..b30506114fb 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.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 { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, pseudonymFactory, externalToolPseudonymEntityFactory } from '@shared/testing'; +import { cleanupCollections, pseudonymFactory, externalToolPseudonymEntityFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { LegacyLogger } from '@src/core/logger'; import { v4 as uuidv4 } from 'uuid'; @@ -118,6 +118,73 @@ describe('ExternalToolPseudonymRepo', () => { }); }); + describe('findPseudonymsByUserId', () => { + describe('when pseudonym is existing', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user1.id, + }); + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user1.id, + }); + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user2.id, + }); + const pseudonym4: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user2.id, + }); + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + + return { + user1, + pseudonym1, + pseudonym2, + }; + }; + + it('should return array of pseudonyms', async () => { + const { user1, pseudonym1, pseudonym2 } = await setup(); + + const result: Pseudonym[] = await repo.findByUserId(user1.id); + + const expectedArray = [ + { + id: pseudonym1.id, + pseudonym: pseudonym1.pseudonym, + toolId: pseudonym1.toolId.toHexString(), + userId: pseudonym1.userId.toHexString(), + createdAt: pseudonym1.createdAt, + updatedAt: pseudonym1.updatedAt, + }, + { + id: pseudonym2.id, + pseudonym: pseudonym2.pseudonym, + toolId: pseudonym2.toolId.toHexString(), + userId: pseudonym2.userId.toHexString(), + createdAt: pseudonym2.createdAt, + updatedAt: pseudonym2.updatedAt, + }, + ]; + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + }); + }); + + describe('should return empty array when there is no pseudonym', () => { + it('should return empty array', async () => { + const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('createOrUpdate', () => { describe('when pseudonym is new', () => { const setup = () => { @@ -169,4 +236,48 @@ describe('ExternalToolPseudonymRepo', () => { }); }); }); + + describe('deletePseudonymsByUserId', () => { + describe('when pseudonyms are existing', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user1.id, + }); + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user1.id, + }); + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user2.id, + }); + const pseudonym4: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + userId: user2.id, + }); + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + + return { + user1, + pseudonym1, + pseudonym2, + }; + }; + + it('should delete all pseudonyms for userId', async () => { + const { user1 } = await setup(); + + const result: number = await repo.deletePseudonymsByUserId(user1.id); + + expect(result).toEqual(2); + }); + }); + + describe('should return empty array when there is no pseudonym', () => { + it('should return empty array', async () => { + const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); + expect(result).toHaveLength(0); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts index 7bb3bb30981..f35f5037ea9 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts @@ -33,6 +33,15 @@ export class ExternalToolPseudonymRepo { return domainObject; } + async findByUserId(userId: EntityId): Promise { + const entities: ExternalToolPseudonymEntity[] = await this.em.find(ExternalToolPseudonymEntity, { + userId: new ObjectId(userId), + }); + const pseudonyms: Pseudonym[] = entities.map((entity) => this.mapEntityToDomainObject(entity)); + + return pseudonyms; + } + async createOrUpdate(domainObject: Pseudonym): Promise { const existing: ExternalToolPseudonymEntity | undefined = this.em .getUnitOfWork() @@ -54,6 +63,14 @@ export class ExternalToolPseudonymRepo { return savedDomainObject; } + async deletePseudonymsByUserId(userId: EntityId): Promise { + const promise: Promise = this.em.nativeDelete(ExternalToolPseudonymEntity, { + userId: new ObjectId(userId), + }); + + return promise; + } + protected mapEntityToDomainObject(entity: ExternalToolPseudonymEntity): Pseudonym { return new Pseudonym({ id: entity.id, 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 1a483e11b03..63440796960 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 { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, pseudonymFactory } from '@shared/testing'; +import { cleanupCollections, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { v4 as uuidv4 } from 'uuid'; import { LegacyLogger } from '@src/core/logger'; @@ -118,6 +118,65 @@ describe('PseudonymRepo', () => { }); }); + describe('findPseudonymsByUserId', () => { + describe('when pseudonym is existing', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const pseudonym1: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user1.id }); + const pseudonym2: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user1.id }); + const pseudonym3: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user2.id }); + const pseudonym4: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user2.id }); + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + + return { + user1, + pseudonym1, + pseudonym2, + }; + }; + + it('should return array of pseudonyms', async () => { + const { user1, pseudonym1, pseudonym2 } = await setup(); + + const result: Pseudonym[] = await repo.findByUserId(user1.id); + + const expectedArray = [ + { + id: pseudonym1.id, + pseudonym: pseudonym1.pseudonym, + toolId: pseudonym1.toolId.toHexString(), + userId: pseudonym1.userId.toHexString(), + createdAt: pseudonym1.createdAt, + updatedAt: pseudonym1.updatedAt, + }, + { + id: pseudonym2.id, + pseudonym: pseudonym2.pseudonym, + toolId: pseudonym2.toolId.toHexString(), + userId: pseudonym2.userId.toHexString(), + createdAt: pseudonym2.createdAt, + updatedAt: pseudonym2.updatedAt, + }, + ]; + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + }); + }); + + describe('should return empty array when there is no pseudonym', () => { + it('should return empty array', async () => { + const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('createOrUpdate', () => { describe('when pseudonym is new', () => { const setup = () => { @@ -169,4 +228,38 @@ describe('PseudonymRepo', () => { }); }); }); + + describe('deletePseudonymsByUserId', () => { + describe('when pseudonyms are existing', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const pseudonym1: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user1.id }); + const pseudonym2: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user1.id }); + const pseudonym3: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user2.id }); + const pseudonym4: PseudonymEntity = pseudonymEntityFactory.buildWithId({ userId: user2.id }); + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + + return { + user1, + }; + }; + + it('should delete all pseudonyms for userId', async () => { + const { user1 } = await setup(); + + const result: number = await repo.deletePseudonymsByUserId(user1.id); + + expect(result).toEqual(2); + }); + }); + + describe('should return empty array when there is no pseudonym', () => { + it('should return empty array', async () => { + const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); + expect(result).toHaveLength(0); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts index 2c1a83aa435..c4e40535ddb 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts @@ -1,7 +1,7 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { EntityId, Pseudonym } from '@shared/domain'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { IPseudonymEntityProps, PseudonymEntity } from '../entity'; +import { PseudonymEntity, PseudonymEntityProps } from '../entity'; @Injectable() export class PseudonymsRepo { @@ -33,12 +33,20 @@ export class PseudonymsRepo { return domainObject; } + async findByUserId(userId: EntityId): Promise { + const entities: PseudonymEntity[] = await this.em.find(PseudonymEntity, { userId: new ObjectId(userId) }); + + const pseudonyms: Pseudonym[] = entities.map((entity) => this.mapEntityToDomainObject(entity)); + + return pseudonyms; + } + async createOrUpdate(domainObject: Pseudonym): Promise { const existing: PseudonymEntity | undefined = this.em .getUnitOfWork() .getById(PseudonymEntity.name, domainObject.id); - const entityProps: IPseudonymEntityProps = this.mapDomainObjectToEntityProperties(domainObject); + const entityProps: PseudonymEntityProps = this.mapDomainObjectToEntityProperties(domainObject); let entity: PseudonymEntity = new PseudonymEntity(entityProps); if (existing) { @@ -54,6 +62,12 @@ export class PseudonymsRepo { return savedDomainObject; } + async deletePseudonymsByUserId(userId: EntityId): Promise { + const promise: Promise = this.em.nativeDelete(PseudonymEntity, { userId: new ObjectId(userId) }); + + return promise; + } + protected mapEntityToDomainObject(entity: PseudonymEntity): Pseudonym { return new Pseudonym({ id: entity.id, @@ -65,8 +79,9 @@ export class PseudonymsRepo { }); } - protected mapDomainObjectToEntityProperties(entityDO: Pseudonym): IPseudonymEntityProps { + protected mapDomainObjectToEntityProperties(entityDO: Pseudonym): PseudonymEntityProps { return { + id: entityDO.id, pseudonym: entityDO.pseudonym, toolId: new ObjectId(entityDO.toolId), userId: new ObjectId(entityDO.userId), diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts index 47845641419..80c5b461c67 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts @@ -1,11 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; -import { externalToolDOFactory, ltiToolDOFactory, pseudonymFactory, userDoFactory } from '@shared/testing/factory'; -import { IToolFeatures, ToolFeatures } from '@src/modules/tool/tool-config'; -import { PseudonymService } from './pseudonym.service'; +import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { externalToolFactory, ltiToolDOFactory, pseudonymFactory, userDoFactory } from '@shared/testing/factory'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; +import { PseudonymService } from './pseudonym.service'; describe('PseudonymService', () => { let module: TestingModule; @@ -13,18 +13,11 @@ describe('PseudonymService', () => { let pseudonymRepo: DeepMocked; let externalToolPseudonymRepo: DeepMocked; - let toolFeatures: IToolFeatures; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ PseudonymService, - { - provide: ToolFeatures, - useValue: { - ctlToolsTabEnabled: true, - }, - }, { provide: PseudonymsRepo, useValue: createMock(), @@ -37,13 +30,11 @@ describe('PseudonymService', () => { }).compile(); service = module.get(PseudonymService); - toolFeatures = module.get(ToolFeatures); pseudonymRepo = module.get(PseudonymsRepo); externalToolPseudonymRepo = module.get(ExternalToolPseudonymRepo); }); beforeEach(() => { - toolFeatures.ctlToolsTabEnabled = true; jest.resetAllMocks(); }); @@ -55,7 +46,7 @@ describe('PseudonymService', () => { describe('when user or tool is missing', () => { const setup = () => { const user: UserDO = userDoFactory.build({ id: undefined }); - const externalTool: ExternalToolDO = externalToolDOFactory.build({ id: undefined }); + const externalTool: ExternalTool = externalToolFactory.build({ id: undefined }); return { user, @@ -70,51 +61,27 @@ describe('PseudonymService', () => { }); }); - describe('when tool parameter is an ExternalToolDO', () => { - describe('when ctl tools tab feature is enabled', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); - - return { - user, - externalTool, - }; - }; - - it('should call externalToolPseudonymRepo', async () => { - const { user, externalTool } = setup(); - - await service.findByUserAndTool(user, externalTool); - - expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); - }); - }); - - describe('when tools feature ctl tools tab is disabled', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); - - toolFeatures.ctlToolsTabEnabled = false; + describe('when tool parameter is an ExternalTool', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); - return { - user, - externalTool, - }; + return { + user, + externalTool, }; + }; - it('should call pseudonymRepo', async () => { - const { user, externalTool } = setup(); + it('should call externalToolPseudonymRepo', async () => { + const { user, externalTool } = setup(); - await service.findByUserAndTool(user, externalTool); + await service.findByUserAndTool(user, externalTool); - expect(pseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); - }); + expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); }); }); - describe('when tool parameter is an LtiToolDO', () => { + describe('when tool parameter is an LtiTool', () => { const setup = () => { const user: UserDO = userDoFactory.buildWithId(); const ltiToolDO: LtiToolDO = ltiToolDOFactory.buildWithId(); @@ -136,9 +103,9 @@ describe('PseudonymService', () => { describe('when searching by userId and toolId', () => { const setup = () => { - const pseudonym: Pseudonym = pseudonymFactory.buildWithId(); + const pseudonym: Pseudonym = pseudonymFactory.build(); const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); externalToolPseudonymRepo.findByUserIdAndToolIdOrFail.mockResolvedValueOnce(pseudonym); @@ -170,7 +137,7 @@ describe('PseudonymService', () => { const setup = () => { externalToolPseudonymRepo.findByUserIdAndToolIdOrFail.mockRejectedValueOnce(new NotFoundException()); const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); return { user, @@ -192,7 +159,7 @@ describe('PseudonymService', () => { describe('when user or tool is missing', () => { const setup = () => { const user: UserDO = userDoFactory.build({ id: undefined }); - const externalTool: ExternalToolDO = externalToolDOFactory.build({ id: undefined }); + const externalTool: ExternalTool = externalToolFactory.build({ id: undefined }); return { user, @@ -212,7 +179,7 @@ describe('PseudonymService', () => { describe('when tool parameter is an ExternalToolDO', () => { const setup = () => { const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); return { user, @@ -251,9 +218,9 @@ describe('PseudonymService', () => { describe('when the pseudonym exists', () => { const setup = () => { - const pseudonym: Pseudonym = pseudonymFactory.buildWithId(); + const pseudonym: Pseudonym = pseudonymFactory.build(); const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); externalToolPseudonymRepo.findByUserIdAndToolId.mockResolvedValueOnce(pseudonym); @@ -280,9 +247,9 @@ describe('PseudonymService', () => { describe('when no pseudonym exists yet', () => { const setup = () => { - const pseudonym: Pseudonym = pseudonymFactory.buildWithId(); + const pseudonym: Pseudonym = pseudonymFactory.build(); const user: UserDO = userDoFactory.buildWithId(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); externalToolPseudonymRepo.findByUserIdAndToolId.mockResolvedValueOnce(null); externalToolPseudonymRepo.createOrUpdate.mockResolvedValueOnce(pseudonym); @@ -316,4 +283,140 @@ describe('PseudonymService', () => { }); }); }); + + describe('findByUserId', () => { + describe('when user is missing', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ id: undefined }); + + return { + user, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(service.findByUserId(user.id as string)).rejects.toThrowError(InternalServerErrorException); + }); + }); + + describe('when searching by userId', () => { + const setup = () => { + const user1: UserDO = userDoFactory.buildWithId(); + const pseudonym1: Pseudonym = pseudonymFactory.build({ userId: user1.id }); + const pseudonym2: Pseudonym = pseudonymFactory.build({ userId: user1.id }); + const pseudonym3: Pseudonym = pseudonymFactory.build({ userId: user1.id }); + const pseudonym4: Pseudonym = pseudonymFactory.build({ userId: user1.id }); + + pseudonymRepo.findByUserId.mockResolvedValue([pseudonym1, pseudonym2]); + externalToolPseudonymRepo.findByUserId.mockResolvedValue([pseudonym3, pseudonym4]); + + return { + user1, + pseudonym1, + pseudonym2, + pseudonym3, + pseudonym4, + }; + }; + + it('should call pseudonymRepo and externalToolPseudonymRepo', async () => { + const { user1 } = setup(); + + await service.findByUserId(user1.id as string); + + expect(pseudonymRepo.findByUserId).toHaveBeenCalledWith(user1.id); + expect(externalToolPseudonymRepo.findByUserId).toHaveBeenCalledWith(user1.id); + }); + + it('should be return array with four pseudonyms', async () => { + const { user1, pseudonym1, pseudonym2, pseudonym3, pseudonym4 } = setup(); + + const result: Pseudonym[] = await service.findByUserId(user1.id as string); + + expect(result).toHaveLength(4); + expect(result[0].id).toEqual(pseudonym1.id); + expect(result[0].userId).toEqual(pseudonym1.userId); + expect(result[0].pseudonym).toEqual(pseudonym1.pseudonym); + expect(result[0].toolId).toEqual(pseudonym1.toolId); + expect(result[0].createdAt).toEqual(pseudonym1.createdAt); + expect(result[0].updatedAt).toEqual(pseudonym1.updatedAt); + expect(result[1].id).toEqual(pseudonym2.id); + expect(result[1].userId).toEqual(pseudonym2.userId); + expect(result[1].pseudonym).toEqual(pseudonym2.pseudonym); + expect(result[1].toolId).toEqual(pseudonym2.toolId); + expect(result[1].createdAt).toEqual(pseudonym2.createdAt); + expect(result[1].updatedAt).toEqual(pseudonym2.updatedAt); + expect(result[2].id).toEqual(pseudonym3.id); + expect(result[2].userId).toEqual(pseudonym3.userId); + expect(result[2].pseudonym).toEqual(pseudonym3.pseudonym); + expect(result[2].toolId).toEqual(pseudonym3.toolId); + expect(result[2].createdAt).toEqual(pseudonym3.createdAt); + expect(result[2].updatedAt).toEqual(pseudonym3.updatedAt); + expect(result[3].id).toEqual(pseudonym4.id); + expect(result[3].userId).toEqual(pseudonym4.userId); + expect(result[3].pseudonym).toEqual(pseudonym4.pseudonym); + expect(result[3].toolId).toEqual(pseudonym4.toolId); + expect(result[3].createdAt).toEqual(pseudonym4.createdAt); + expect(result[3].updatedAt).toEqual(pseudonym4.updatedAt); + }); + }); + + describe('should return empty array when there is no pseudonym', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + return { + user, + }; + }; + it('should return empty array', async () => { + const { user } = setup(); + + const result: Pseudonym[] = await service.findByUserId(user.id as string); + + expect(result).toHaveLength(0); + }); + }); + }); + + describe('deleteByUserId', () => { + describe('when user is missing', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ id: undefined }); + + return { + user, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(service.deleteByUserId(user.id as string)).rejects.toThrowError(InternalServerErrorException); + }); + }); + + describe('when deleting by userId', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + pseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(2); + externalToolPseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(3); + + return { + user, + }; + }; + + it('should delete pseudonyms by userId', async () => { + const { user } = setup(); + + const result5 = await service.deleteByUserId(user.id as string); + + expect(result5).toEqual(5); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts index 86e3a8599b8..9df391dbd33 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts @@ -1,19 +1,18 @@ -import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; -import { ExternalToolDO, LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; -import { v4 as uuidv4 } from 'uuid'; -import { IToolFeatures, ToolFeatures } from '@src/modules/tool/tool-config'; import { ObjectId } from '@mikro-orm/mongodb'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { v4 as uuidv4 } from 'uuid'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; @Injectable() export class PseudonymService { constructor( - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, private readonly pseudonymRepo: PseudonymsRepo, private readonly externalToolPseudonymRepo: ExternalToolPseudonymRepo ) {} - public async findByUserAndTool(user: UserDO, tool: ExternalToolDO | LtiToolDO): Promise { + public async findByUserAndTool(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { if (!user.id || !tool.id) { throw new InternalServerErrorException('User or tool id is missing'); } @@ -23,7 +22,30 @@ export class PseudonymService { return pseudonymPromise; } - public async findOrCreatePseudonym(user: UserDO, tool: ExternalToolDO | LtiToolDO): Promise { + public async findByUserId(userId: string): Promise { + if (!userId) { + throw new InternalServerErrorException('User id is missing'); + } + + let [pseudonyms, externalToolPseudonyms] = await Promise.all([ + this.findPseudonymsByUserId(userId), + this.findExternalToolPseudonymsByUserId(userId), + ]); + + if (pseudonyms === undefined) { + pseudonyms = []; + } + + if (externalToolPseudonyms === undefined) { + externalToolPseudonyms = []; + } + + const allPseudonyms = [...pseudonyms, ...externalToolPseudonyms]; + + return allPseudonyms; + } + + public async findOrCreatePseudonym(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { if (!user.id || !tool.id) { throw new InternalServerErrorException('User or tool id is missing'); } @@ -47,10 +69,49 @@ export class PseudonymService { return pseudonym; } - private getRepository(tool: ExternalToolDO | LtiToolDO): PseudonymsRepo | ExternalToolPseudonymRepo { - if (this.toolFeatures.ctlToolsTabEnabled && tool instanceof ExternalToolDO) { + public async deleteByUserId(userId: string): Promise { + if (!userId) { + throw new InternalServerErrorException('User id is missing'); + } + + const [deletedPseudonyms, deletedExternalToolPseudonyms] = await Promise.all([ + this.deletePseudonymsByUserId(userId), + this.deleteExternalToolPseudonymsByUserId(userId), + ]); + + return deletedPseudonyms + deletedExternalToolPseudonyms; + } + + private async findPseudonymsByUserId(userId: string): Promise { + const pseudonymPromise: Promise = this.pseudonymRepo.findByUserId(userId); + + return pseudonymPromise; + } + + private async findExternalToolPseudonymsByUserId(userId: string): Promise { + const externalToolPseudonymPromise: Promise = this.externalToolPseudonymRepo.findByUserId(userId); + + return externalToolPseudonymPromise; + } + + private async deletePseudonymsByUserId(userId: string): Promise { + const pseudonymPromise: Promise = this.pseudonymRepo.deletePseudonymsByUserId(userId); + + return pseudonymPromise; + } + + private async deleteExternalToolPseudonymsByUserId(userId: string): Promise { + const externalToolPseudonymPromise: Promise = + this.externalToolPseudonymRepo.deletePseudonymsByUserId(userId); + + return externalToolPseudonymPromise; + } + + private getRepository(tool: ExternalTool | LtiToolDO): PseudonymsRepo | ExternalToolPseudonymRepo { + if (tool instanceof ExternalTool) { return this.externalToolPseudonymRepo; } + return this.pseudonymRepo; } } diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index ff7ae2f5367..ad338bae642 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -16,6 +16,7 @@ import { AuthenticationApiModule } from '@src/modules/authentication/authenticat import { BoardApiModule } from '@src/modules/board/board-api.module'; import { CollaborativeStorageModule } from '@src/modules/collaborative-storage'; import { FilesStorageClientModule } from '@src/modules/files-storage-client'; +import { GroupApiModule } from '@src/modules/group/group-api.module'; import { LearnroomApiModule } from '@src/modules/learnroom/learnroom-api.module'; import { LessonApiModule } from '@src/modules/lesson/lesson-api.module'; import { NewsModule } from '@src/modules/news'; @@ -25,7 +26,6 @@ import { RocketChatModule } from '@src/modules/rocketchat'; import { SchoolApiModule } from '@src/modules/school/school-api.module'; import { SharingApiModule } from '@src/modules/sharing/sharing.module'; import { SystemApiModule } from '@src/modules/system/system-api.module'; -import { TaskCardModule } from '@src/modules/task-card'; import { TaskApiModule } from '@src/modules/task/task-api.module'; import { ToolApiModule } from '@src/modules/tool/tool-api.module'; import { ImportUserModule } from '@src/modules/user-import'; @@ -35,6 +35,7 @@ import { VideoConferenceApiModule } from '@src/modules/video-conference/video-co import connectRedis from 'connect-redis'; import session from 'express-session'; import { RedisClient } from 'redis'; +import { TeamsApiModule } from '@src/modules/teams/teams-api.module'; import { ServerController } from './controller/server.controller'; import { serverConfig } from './server.config'; @@ -46,7 +47,6 @@ const serverModules = [ CollaborativeStorageModule, OauthApiModule, TaskApiModule, - TaskCardModule, LessonApiModule, NewsModule, UserApiModule, @@ -72,6 +72,8 @@ const serverModules = [ ToolApiModule, UserLoginMigrationApiModule, BoardApiModule, + GroupApiModule, + TeamsApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/modules/task-card/controller/api-test/task-card.api.spec.ts b/apps/server/src/modules/task-card/controller/api-test/task-card.api.spec.ts deleted file mode 100644 index 842136fd1c0..00000000000 --- a/apps/server/src/modules/task-card/controller/api-test/task-card.api.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { sanitizeRichText } from '@shared/controller'; -import { CardElementType, InputFormat, Permission } from '@shared/domain'; -import { - TestApiClient, - UserAndAccountTestFactory, - cleanupCollections, - courseFactory, - richTextCardElementFactory, - taskCardFactory, - taskFactory, -} from '@shared/testing'; -import { ServerTestModule } from '@src/modules/server/server.module'; -import { TaskCardResponse } from '@src/modules/task-card/controller/dto'; - -const createTeacher = () => { - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [ - Permission.TASK_DASHBOARD_TEACHER_VIEW_V3, - ]); - return { account: teacherAccount, user: teacherUser }; -}; - -const inTwoDays = new Date(Date.now() + 172800000); -const inThreeDays = new Date(Date.now() + 259200000); - -describe('Task-Card Controller (API)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }).compile(); - - app = module.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - testApiClient = new TestApiClient(app, 'cards/task'); - }); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(async () => { - await cleanupCollections(em); - Configuration.set('FEATURE_TASK_CARD_ENABLED', true); - }); - - describe('[POST] /cards/task', () => { - describe('when a teacher of a course is given', () => { - const setup = async () => { - const { account, user } = createTeacher(); - const course = courseFactory.build({ teachers: [user] }); - - await em.persistAndFlush([account, user, course]); - em.clear(); - - const loggedInClient = await testApiClient.login(account); - - return { loggedInClient, teacher: user, course }; - }; - it('should return new a task card', async () => { - const { loggedInClient, course } = await setup(); - - const taskCardParams = { - title: 'test title', - cardElements: [ - { - content: { - type: 'richText', - value: 'rich 2', - inputFormat: 'richTextCk5', - }, - }, - { - content: { - type: 'richText', - value: 'rich 2', - inputFormat: 'richTextCk5', - }, - }, - ], - courseId: course.id, - dueDate: inTwoDays, - }; - - const response = await loggedInClient.post(undefined, taskCardParams); - const { statusCode } = response; - const responseTaskCard = response.body as TaskCardResponse; - - expect(statusCode).toEqual(201); - expect(responseTaskCard.cardElements?.length).toEqual(2); - expect(responseTaskCard.task.name).toEqual('test title'); - expect(responseTaskCard.title).toEqual('test title'); - expect(responseTaskCard.visibleAtDate).toBeDefined(); - expect(responseTaskCard.dueDate).toBe(inTwoDays.toISOString()); - expect(responseTaskCard.courseId).toEqual(course.id); - expect(responseTaskCard.courseName).toEqual(course.name); - expect(responseTaskCard.task.taskCardId).toEqual(responseTaskCard.id); - expect(responseTaskCard.task.status.isDraft).toEqual(false); - }); - - it('should sanitize richtext on create with inputformat ck5', async () => { - const { loggedInClient, course } = await setup(); - - const text = ' some more text'; - - const taskCardParams = { - title: 'test title', - courseId: course.id, - cardElements: [ - { - content: { - type: 'richText', - value: text, - inputFormat: InputFormat.RICH_TEXT_CK5, - }, - }, - ], - dueDate: inTwoDays, - }; - - const sanitizedText = sanitizeRichText(text, InputFormat.RICH_TEXT_CK5); - - const response = await loggedInClient.post(undefined, taskCardParams); - const { statusCode } = response; - const responseTaskCard = response.body as TaskCardResponse; - expect(statusCode).toEqual(201); - const richTextElement = responseTaskCard.cardElements?.filter( - (element) => element.cardElementType === CardElementType.RichText - ); - if (richTextElement?.[0]?.content) { - expect(richTextElement[0].content.value).toEqual(sanitizedText); - } - }); - - it('should throw if feature is NOT enabled', async () => { - const { loggedInClient, course } = await setup(); - Configuration.set('FEATURE_TASK_CARD_ENABLED', false); - - const taskCardParams = { - title: 'title', - courseId: course.id, - dueDate: inThreeDays, - }; - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(500); - }); - it('should throw if courseId is empty', async () => { - const { loggedInClient } = await setup(); - - const taskCardParams = { - title: 'test title', - cardElements: [], - courseId: '', - }; - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - it('should throw if no course is matching', async () => { - const { loggedInClient } = await setup(); - - const taskCardParams = { - title: 'test title', - cardElements: [], - courseId: new ObjectId().toHexString(), - }; - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - it('should throw if dueDate is empty', async () => { - const { loggedInClient, course } = await setup(); - - const taskCardParams = { - title: 'test title', - cardElements: [], - courseId: course.id, - }; - - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - it('should throw if dueDate is earlier than today', async () => { - const { loggedInClient, course } = await setup(); - - const taskCardParams = { - title: 'test title', - cardElements: [], - courseId: course.id, - dueDate: new Date(Date.now() - 259200000), - }; - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - it('should throw if title is empty', async () => { - const { loggedInClient } = await setup(); - - const taskCardParams = { - title: '', - }; - - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - it('should throw if title is not a string', async () => { - const { loggedInClient } = await setup(); - - const taskCardParams = { - title: 1234, - }; - - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(501); - }); - it('should throw if title is not provided', async () => { - const { loggedInClient } = await setup(); - - const taskCardParams = {}; - - const { statusCode } = await loggedInClient.post(undefined, taskCardParams); - expect(statusCode).toEqual(400); - }); - }); - }); - - describe('[GET] /cards/task/:id', () => { - const setup = async () => { - const { account, user } = createTeacher(); - const course = courseFactory.build({ teachers: [user] }); - // for some reason taskCard factory messes up the creator of task, so it needs to be separated - const task = taskFactory.build({ name: 'title', creator: user }); - const taskCard = taskCardFactory.buildWithId({ creator: user, task }); - await em.persistAndFlush([account, user, course, task, taskCard]); - em.clear(); - - const loggedInClient = await testApiClient.login(account); - - return { loggedInClient, teacher: user, course, task, taskCard }; - }; - - describe('when teacher and taskcard is given', () => { - it('should return existing task-card', async () => { - const { loggedInClient, taskCard } = await setup(); - - const response = await loggedInClient.get(`${taskCard.id}`); - const { statusCode } = response; - const responseTaskCard = response.body as TaskCardResponse; - - expect(statusCode).toBe(200); - expect(responseTaskCard.id).toEqual(taskCard.id); - }); - - it('should throw if feature not enabled', async () => { - const { loggedInClient, taskCard } = await setup(); - Configuration.set('FEATURE_TASK_CARD_ENABLED', false); - - const { statusCode } = await loggedInClient.get(`${taskCard.id}`); - expect(statusCode).toBe(500); - }); - }); - }); - - describe('[PATCH] /cards/task/:id', () => { - describe('when teacher and taskcard is given', () => { - const setup = async () => { - const { account, user } = createTeacher(); - const course = courseFactory.build({ teachers: [user] }); - // for some reason taskCard factory messes up the creator of task, so it needs to be separated - const task = taskFactory.build({ name: 'title', creator: user }); - const taskCard = taskCardFactory.buildWithId({ creator: user, task }); - await em.persistAndFlush([account, user, course, task, taskCard]); - em.clear(); - - const loggedInClient = await testApiClient.login(account); - - return { loggedInClient, teacher: user, course, task, taskCard }; - }; - - it('should update the task card', async () => { - const { loggedInClient, taskCard, course } = await setup(); - - const richTextCardElement = richTextCardElementFactory.buildWithId(); - - const taskCardUpdateParams = { - title: 'updated title', - cardElements: [ - { - id: richTextCardElement.id, - content: { - type: 'richText', - value: 'rich updated', - inputFormat: 'richTextCk5', - }, - }, - { - content: { - type: 'richText', - value: 'rich added', - inputFormat: 'richTextCk5', - }, - }, - ], - visibleAtDate: inTwoDays, - dueDate: inThreeDays, - courseId: course.id, - }; - - const response = await loggedInClient.patch(`${taskCard.id}`, taskCardUpdateParams); - const { statusCode } = response; - const responseTaskCard = response.body as TaskCardResponse; - - expect(statusCode).toBe(200); - expect(responseTaskCard.id).toEqual(taskCard.id); - expect(responseTaskCard.title).toEqual(taskCardUpdateParams.title); - expect(responseTaskCard.cardElements?.length).toEqual(2); - expect(new Date(responseTaskCard.visibleAtDate)).toEqual(inTwoDays); - expect(new Date(responseTaskCard.dueDate)).toEqual(inThreeDays); - }); - - it('should sanitize richtext on update with inputformat ck5', async () => { - const { loggedInClient, taskCard, course } = await setup(); - - const text = ' some more text'; - const sanitizedText = sanitizeRichText(text, InputFormat.RICH_TEXT_CK5); - - const taskCardUpdateParams = { - title: 'test title updated', - courseId: course.id, - cardElements: [ - { - content: { - type: 'richText', - value: text, - inputFormat: InputFormat.RICH_TEXT_CK5, - }, - }, - ], - dueDate: inTwoDays, - }; - - const response = await loggedInClient.patch(`${taskCard.id}`, taskCardUpdateParams); - const { statusCode } = response; - const responseTaskCard = response.body as TaskCardResponse; - - expect(statusCode).toBe(200); - const richTextElement = responseTaskCard.cardElements?.filter( - (element) => element.cardElementType === CardElementType.RichText - ); - const expectedRichTextElement = richTextElement ? richTextElement[0].content.value : ''; - expect(expectedRichTextElement).toEqual(sanitizedText); - }); - - it('should throw if feature is not enabled', async () => { - const { loggedInClient, course, taskCard } = await setup(); - Configuration.set('FEATURE_TASK_CARD_ENABLED', false); - - const taskCardUpdateParams = { - title: 'title', - courseId: course.id, - dueDate: inThreeDays, - }; - - const { statusCode } = await loggedInClient.patch(`${taskCard.id}`, taskCardUpdateParams); - - expect(statusCode).toBe(500); - }); - }); - }); - - describe('[DELETE] /cards/task/:id', () => { - describe('when logged in as a teacher', () => { - const setup = async () => { - const { account, user } = createTeacher(); - const course = courseFactory.build({ teachers: [user] }); - // for some reason taskCard factory messes up the creator of task, so it needs to be separated - const task = taskFactory.build({ name: 'title', creator: user }); - const taskCard = taskCardFactory.buildWithId({ creator: user, task }); - await em.persistAndFlush([account, user, course, task, taskCard]); - em.clear(); - - const teacherClient = await testApiClient.login(account); - - return { teacherClient, teacher: user, course, task, taskCard }; - }; - - it('should return status 200 for valid task card', async () => { - const { teacherClient, taskCard } = await setup(); - - const response = await teacherClient.delete(`${taskCard.id}`); - - expect(response.status).toEqual(200); - }); - }); - }); -}); diff --git a/apps/server/src/modules/task-card/controller/dto/index.ts b/apps/server/src/modules/task-card/controller/dto/index.ts deleted file mode 100644 index 9169a914787..00000000000 --- a/apps/server/src/modules/task-card/controller/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './task-card.response'; -export * from './task-card.url.params'; -export * from './task-card.params'; diff --git a/apps/server/src/modules/task-card/controller/dto/task-card.params.ts b/apps/server/src/modules/task-card/controller/dto/task-card.params.ts deleted file mode 100644 index ad3352bd7c2..00000000000 --- a/apps/server/src/modules/task-card/controller/dto/task-card.params.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; -import { SanitizeHtml } from '@shared/controller'; -import { CardElementType, InputFormat } from '@shared/domain'; -import { Type } from 'class-transformer'; -import { - IsArray, - IsDate, - IsEnum, - IsMongoId, - IsOptional, - IsString, - MaxLength, - MinDate, - MinLength, - ValidateNested, -} from 'class-validator'; - -export abstract class CardElementBase {} - -export class RichTextCardElementParam extends CardElementBase { - @ApiProperty({ - description: 'Type of card element, i.e. richText (needed for discriminator)', - type: String, - example: CardElementType.RichText, - }) - type = CardElementType.RichText; - - @ApiProperty({ - description: 'Content of the rich text card element', - required: true, - }) - @IsString() - value!: string; - - @ApiProperty({ description: 'Input format of card element content', enum: InputFormat }) - @IsEnum(InputFormat) - inputFormat!: InputFormat; -} - -@ApiExtraModels(RichTextCardElementParam) -export class CardElementParams { - @ApiPropertyOptional() - @IsOptional() - @IsString() - @IsMongoId() - id?: string; - - @ApiProperty({ - description: 'Content of the card element, depending on its type', - required: true, - oneOf: [{ $ref: getSchemaPath(RichTextCardElementParam) }], - }) - @ValidateNested() - @Type(() => CardElementBase, { - discriminator: { - property: 'type', - subTypes: [{ value: RichTextCardElementParam, name: CardElementType.RichText }], - }, - }) - content!: RichTextCardElementParam; -} - -export class TaskCardParams { - @IsString() - @IsMongoId() - @ApiProperty({ - description: 'The id of an course object.', - pattern: '[a-f0-9]{24}', - }) - courseId!: string; - - @IsString() - @MinLength(1) - @MaxLength(400) - @SanitizeHtml() - @ApiProperty({ - description: 'The title of the card', - }) - title!: string; - - @IsOptional() - @IsDate() - @MinDate(new Date()) - @ApiPropertyOptional({ description: 'Visible at date of the card' }) - visibleAtDate?: Date; - - @IsDate() - @MinDate(new Date()) - @ApiProperty({ description: 'Due date of the card' }) - dueDate!: Date; - - @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CardElementParams) - @ApiPropertyOptional({ - description: 'Card elements array', - type: [CardElementParams], - }) - cardElements?: CardElementParams[]; -} diff --git a/apps/server/src/modules/task-card/controller/dto/task-card.response.ts b/apps/server/src/modules/task-card/controller/dto/task-card.response.ts deleted file mode 100644 index ebbc38aad4b..00000000000 --- a/apps/server/src/modules/task-card/controller/dto/task-card.response.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { DecodeHtmlEntities } from '@shared/controller'; -import { CardElementResponse } from '@shared/domain'; -import { TaskResponse } from '@src/modules/task/controller/dto'; - -export class TaskCardResponse { - constructor({ - id, - draggable, - cardElements, - task, - visibleAtDate, - dueDate, - title, - courseId, - courseName, - }: TaskCardResponse) { - this.id = id; - this.draggable = draggable; - this.task = task; - this.visibleAtDate = visibleAtDate; - this.dueDate = dueDate; - this.title = title; - this.cardElements = cardElements; - this.courseId = courseId; - this.courseName = courseName; - } - - @ApiProperty({ - description: 'The id of the task card', - pattern: '[a-f0-9]{24}', - }) - id: string; - - @ApiProperty({ - description: 'The title of the task card', - }) - title: string; - - @ApiPropertyOptional({ - description: 'Array of card elements', - type: [CardElementResponse], - }) - cardElements?: CardElementResponse[]; - - @ApiProperty() - @DecodeHtmlEntities() - courseName: string; - - @ApiProperty() - courseId: string; - - @ApiProperty({ - description: 'Are the card elements draggable?', - }) - draggable: boolean; - - @ApiProperty({ - description: 'The task attached to the card', - }) - task: TaskResponse; - - @ApiProperty({ - description: 'Visible at date of the task card', - }) - visibleAtDate: Date; - - @ApiProperty({ - description: 'Due date of the task card', - }) - dueDate: Date; -} diff --git a/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.spec.ts b/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.spec.ts deleted file mode 100644 index dc743ee2a5a..00000000000 --- a/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.spec.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { - CardElementResponse, - CardElementType, - CardRichTextElementResponse, - InputFormat, - RichText, - RichTextCardElement, - TaskWithStatusVo, -} from '@shared/domain'; -import { - courseFactory, - richTextCardElementFactory, - setupEntities, - taskCardFactory, - userFactory, -} from '@shared/testing'; -import { RichTextCardElementParam } from '@src/modules/task-card/controller/dto'; -import { TaskMapper } from '@src/modules/task/mapper'; -import { TaskCardMapper } from './task-card.mapper'; - -describe('task-card mapper', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('mapToResponse', () => { - it('should map task-card to response', () => { - const user = userFactory.buildWithId(); - const course = courseFactory.buildWithId(); - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - - const taskCard = taskCardFactory.buildWithId({ - creator: user, - course, - visibleAtDate: tomorrow, - dueDate: inTwoDays, - }); - const status = taskCard.task.createTeacherStatusForUser(user); - - const taskWithStatusVo = new TaskWithStatusVo(taskCard.task, status); - const taskResponse = TaskMapper.mapToResponse(taskWithStatusVo); - - const mapper = new TaskCardMapper(); - const result = mapper.mapToResponse(taskCard, taskWithStatusVo); - - expect(result).toEqual({ - title: taskCard.title, - id: taskCard.id, - draggable: true, - courseId: course.id, - courseName: course.name, - task: taskResponse, - visibleAtDate: tomorrow, - dueDate: inTwoDays, - }); - }); - it('should map card elements to response', () => { - const user = userFactory.buildWithId(); - const richTextCardElement: RichTextCardElement = richTextCardElementFactory.buildWithId(); - - const richtTextCardElementResponse: CardElementResponse = { - id: richTextCardElement.id, - cardElementType: CardElementType.RichText, - content: new CardRichTextElementResponse(richTextCardElement), - }; - - const taskCard = taskCardFactory.buildWithId({ - creator: user, - cardElements: [richTextCardElement], - }); - - const status = taskCard.task.createTeacherStatusForUser(user); - const taskWithStatusVo = new TaskWithStatusVo(taskCard.task, status); - - const mapper = new TaskCardMapper(); - const result = mapper.mapToResponse(taskCard, taskWithStatusVo); - - expect(result.cardElements ? result.cardElements[0] : '').toEqual( - expect.objectContaining({ ...richtTextCardElementResponse }) - ); - }); - }); - - describe('mapToDomain', () => { - it('should map params to domain', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - - const cardElementRichText = new RichTextCardElementParam(); - cardElementRichText.type = CardElementType.RichText; - cardElementRichText.value = 'rich text'; - cardElementRichText.inputFormat = InputFormat.RICH_TEXT_CK5; - - const params = { - cardElements: [ - { - content: cardElementRichText, - }, - ], - courseId: new ObjectId().toHexString(), - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: 'test-title', - }; - const result = TaskCardMapper.mapToDomain(params); - - const expectedDto = { - title: 'test-title', - text: [new RichText({ content: 'rich text', type: InputFormat.RICH_TEXT_CK5 })], - courseId: params.courseId, - visibleAtDate: tomorrow, - dueDate: inTwoDays, - }; - expect(result).toEqual(expectedDto); - }); - it('should map update params to domain', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - const courseId = new ObjectId().toHexString(); - - const cardElementRichText1 = new RichTextCardElementParam(); - cardElementRichText1.type = CardElementType.RichText; - cardElementRichText1.value = 'update rich text 1'; - cardElementRichText1.inputFormat = InputFormat.RICH_TEXT_CK5; - - const cardElementRichText2 = new RichTextCardElementParam(); - cardElementRichText2.type = CardElementType.RichText; - cardElementRichText2.value = 'update rich text 2'; - cardElementRichText2.inputFormat = InputFormat.RICH_TEXT_CK5; - - const params = { - cardElements: [ - { - content: cardElementRichText1, - }, - { - content: cardElementRichText2, - }, - ], - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: 'update title', - courseId, - }; - const result = TaskCardMapper.mapToDomain(params); - - const expectedDto = { - title: 'update title', - text: [ - new RichText({ content: 'update rich text 1', type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: 'update rich text 2', type: InputFormat.RICH_TEXT_CK5 }), - ], - visibleAtDate: tomorrow, - dueDate: inTwoDays, - courseId, - }; - expect(result).toEqual(expectedDto); - }); - it('should should throw an error if title is empty', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - - const cardElementRichText = new RichTextCardElementParam(); - cardElementRichText.type = CardElementType.RichText; - cardElementRichText.value = 'rich text'; - cardElementRichText.inputFormat = InputFormat.RICH_TEXT_CK5; - - const params = { - cardElements: [ - { - content: cardElementRichText, - }, - ], - courseId: new ObjectId().toHexString(), - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: '', - }; - expect(() => TaskCardMapper.mapToDomain(params)).toThrowError(); - }); - it('should throw an error if courseId is empty', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - - const cardElementRichText = new RichTextCardElementParam(); - cardElementRichText.type = CardElementType.RichText; - cardElementRichText.value = 'rich text'; - cardElementRichText.inputFormat = InputFormat.RICH_TEXT_CK5; - - const params = { - cardElements: [ - { - content: cardElementRichText, - }, - ], - courseId: '', - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: 'test-title', - }; - expect(() => TaskCardMapper.mapToDomain(params)).toThrowError(); - }); - it('should not have a text property if cardElements are missing', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - - const params = { - courseId: new ObjectId().toHexString(), - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: 'test-title', - }; - const result = TaskCardMapper.mapToDomain(params); - - expect(result.text).toBeUndefined(); - }); - }); -}); diff --git a/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.ts b/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.ts deleted file mode 100644 index e844acba6df..00000000000 --- a/apps/server/src/modules/task-card/controller/mapper/task-card.mapper.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { ValidationError } from '@shared/common'; -import { - CardElement, - CardElementResponse, - CardRichTextElementResponse, - RichText, - TaskCard, - TaskWithStatusVo, -} from '@shared/domain'; -import { CardElementType, RichTextCardElement } from '@shared/domain/entity/card-element.entity'; -import { TaskResponse } from '@src/modules/task/controller/dto'; -import { TaskMapper } from '@src/modules/task/mapper'; -import { ITaskCardCRUD } from '../../interface'; -import { CardElementParams, RichTextCardElementParam, TaskCardParams, TaskCardResponse } from '../dto'; - -export class TaskCardMapper { - mapToResponse(card: TaskCard, taskWithStatusVo: TaskWithStatusVo): TaskCardResponse { - const taskResponse: TaskResponse = TaskMapper.mapToResponse(taskWithStatusVo); - - const dto = new TaskCardResponse({ - id: card.id, - draggable: card.draggable, - task: taskResponse, - visibleAtDate: card.visibleAtDate, - dueDate: card.dueDate, - title: card.title, - courseId: card.course.id, - courseName: card.course.name, - }); - if (card.cardElements.length) { - dto.cardElements = this.getCardElementResponse(card); - } - - return dto; - } - - private mapElements(cardElements: CardElement[]): CardElementResponse[] { - const cardElementsResponse: CardElementResponse[] = []; - cardElements.forEach((element) => { - if (element.cardElementType === CardElementType.RichText) { - const content = new CardRichTextElementResponse(element as RichTextCardElement); - const response = { id: element.id, cardElementType: element.cardElementType, content }; - cardElementsResponse.push(response); - } - }); - - return cardElementsResponse; - } - - private getCardElementResponse(card: TaskCard): CardElementResponse[] { - const cardElements = card.getCardElements(); - const cardElementsResponse = this.mapElements(cardElements); - return cardElementsResponse; - } - - static mapToDomain(params: TaskCardParams): ITaskCardCRUD { - if (!params.title || params.title.length === 0) { - throw new ValidationError('The Task Card must have one title'); - } - if (!params.courseId || params.courseId.length === 0) { - throw new ValidationError('The Task Card must have one course'); - } - - const dto: ITaskCardCRUD = { - title: params.title, - courseId: params.courseId, - dueDate: params.dueDate, - }; - - if (params.visibleAtDate) { - dto.visibleAtDate = params.visibleAtDate; - } - - if (params.cardElements) { - const text = this.mapElementsToDto(params.cardElements); - if (text) { - dto.text = text; - } - } - - return dto; - } - - private static mapElementsToDto(cardElements: CardElementParams[]): RichText[] | void { - const text: RichText[] = []; - cardElements.forEach((element) => { - if (element.content instanceof RichTextCardElementParam) { - const richText = new RichText({ content: element.content.value, type: element.content.inputFormat }); - text.push(richText); - } - }); - - return text; - } -} diff --git a/apps/server/src/modules/task-card/controller/task-card.controller.ts b/apps/server/src/modules/task-card/controller/task-card.controller.ts deleted file mode 100644 index d547744f308..00000000000 --- a/apps/server/src/modules/task-card/controller/task-card.controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; - -import { Body, Controller, Delete, Get, InternalServerErrorException, Param, Patch, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { ICurrentUser } from '@src/modules/authentication'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; -import { TaskCardUc } from '../uc'; -import { TaskCardParams, TaskCardResponse, TaskCardUrlParams } from './dto'; -import { TaskCardMapper } from './mapper/task-card.mapper'; - -@ApiTags('Cards') -@Authenticate('jwt') -@Controller('cards/task') -export class TaskCardController { - constructor(private readonly taskCardUc: TaskCardUc) {} - - @Post() - async create(@CurrentUser() currentUser: ICurrentUser, @Body() params: TaskCardParams): Promise { - this.featureEnabled(); - - const mapper = new TaskCardMapper(); - const { card, taskWithStatusVo } = await this.taskCardUc.create( - currentUser.userId, - TaskCardMapper.mapToDomain(params) - ); - const taskCardResponse = mapper.mapToResponse(card, taskWithStatusVo); - - return taskCardResponse; - } - - @Get(':id') - async findOne( - @CurrentUser() currentUser: ICurrentUser, - @Param() urlParams: TaskCardUrlParams - ): Promise { - this.featureEnabled(); - - const { card, taskWithStatusVo } = await this.taskCardUc.findOne(currentUser.userId, urlParams.id); - const mapper = new TaskCardMapper(); - const taskCardResponse = mapper.mapToResponse(card, taskWithStatusVo); - return taskCardResponse; - } - - @Delete(':id') - async delete(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: TaskCardUrlParams): Promise { - this.featureEnabled(); - - const result = await this.taskCardUc.delete(currentUser.userId, urlParams.id); - - return result; - } - - @Patch(':id') - async update( - @CurrentUser() currentUser: ICurrentUser, - @Param() urlParams: TaskCardUrlParams, - @Body() params: TaskCardParams - ): Promise { - this.featureEnabled(); - - const { card, taskWithStatusVo } = await this.taskCardUc.update( - currentUser.userId, - urlParams.id, - TaskCardMapper.mapToDomain(params) - ); - const mapper = new TaskCardMapper(); - const taskCardResponse = mapper.mapToResponse(card, taskWithStatusVo); - return taskCardResponse; - } - - private featureEnabled() { - const enabled = Configuration.get('FEATURE_TASK_CARD_ENABLED') as boolean; - if (!enabled) { - throw new InternalServerErrorException('Feature not enabled'); - } - } -} diff --git a/apps/server/src/modules/task-card/index.ts b/apps/server/src/modules/task-card/index.ts deleted file mode 100644 index 266d197c826..00000000000 --- a/apps/server/src/modules/task-card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './task-card.module'; diff --git a/apps/server/src/modules/task-card/interface/index.ts b/apps/server/src/modules/task-card/interface/index.ts deleted file mode 100644 index 68f7b77cde9..00000000000 --- a/apps/server/src/modules/task-card/interface/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './task-card-crud'; diff --git a/apps/server/src/modules/task-card/interface/task-card-crud.ts b/apps/server/src/modules/task-card/interface/task-card-crud.ts deleted file mode 100644 index 31dd6d94ea9..00000000000 --- a/apps/server/src/modules/task-card/interface/task-card-crud.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RichText } from '@shared/domain/types'; - -export interface ITaskCardCRUD { - id?: string; - courseId: string; - title: string; - text?: RichText[]; - visibleAtDate?: Date; - dueDate: Date; -} diff --git a/apps/server/src/modules/task-card/task-card.module.ts b/apps/server/src/modules/task-card/task-card.module.ts deleted file mode 100644 index 0d362acc2da..00000000000 --- a/apps/server/src/modules/task-card/task-card.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CardElementRepo, CourseRepo, RichTextCardElementRepo, TaskCardRepo } from '@shared/repo'; -import { AuthorizationModule } from '../authorization/authorization.module'; -import { TaskModule } from '../task/task.module'; -import { TaskCardController } from './controller/task-card.controller'; -import { TaskCardUc } from './uc/task-card.uc'; - -@Module({ - imports: [AuthorizationModule, TaskModule], - controllers: [TaskCardController], - providers: [TaskCardUc, CardElementRepo, CourseRepo, TaskCardRepo, RichTextCardElementRepo], - exports: [], -}) -export class TaskCardModule {} diff --git a/apps/server/src/modules/task-card/uc/index.ts b/apps/server/src/modules/task-card/uc/index.ts deleted file mode 100644 index 5b930d38299..00000000000 --- a/apps/server/src/modules/task-card/uc/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './task-card.uc'; diff --git a/apps/server/src/modules/task-card/uc/task-card.uc.spec.ts b/apps/server/src/modules/task-card/uc/task-card.uc.spec.ts deleted file mode 100644 index 15448cd69ad..00000000000 --- a/apps/server/src/modules/task-card/uc/task-card.uc.spec.ts +++ /dev/null @@ -1,593 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ForbiddenException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ValidationError } from '@shared/common/error'; -import { CardType, Course, InputFormat, Permission, TaskCard, TaskWithStatusVo, User } from '@shared/domain'; -import { CardElementType, RichTextCardElement } from '@shared/domain/entity/card-element.entity'; -import { RichText } from '@shared/domain/types/rich-text.types'; -import { CardElementRepo, CourseRepo, RichTextCardElementRepo, TaskCardRepo, UserRepo } from '@shared/repo'; -import { - courseFactory, - richTextCardElementFactory, - schoolFactory, - setupEntities, - taskCardFactory, - userFactory, -} from '@shared/testing'; -import { Action, AuthorizationService } from '@src/modules/authorization'; -import { TaskService } from '@src/modules/task/service'; -import { ITaskCardCRUD } from '../interface'; -import { TaskCardUc } from './task-card.uc'; - -describe('TaskCardUc', () => { - let module: TestingModule; - let uc: TaskCardUc; - let cardElementRepo: DeepMocked; - let courseRepo: DeepMocked; - let taskCardRepo: DeepMocked; - let userRepo: DeepMocked; - let authorizationService: DeepMocked; - let taskService: DeepMocked; - let taskCard: TaskCard; - let user!: User; - let course: Course; - - beforeAll(async () => { - await setupEntities(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2023-01-23T09:34:54.854Z')); - - module = await Test.createTestingModule({ - imports: [], - providers: [ - TaskCardUc, - { - provide: TaskCardRepo, - useValue: createMock(), - }, - { - provide: CardElementRepo, - useValue: createMock(), - }, - { - provide: RichTextCardElementRepo, - useValue: createMock(), - }, - { - provide: UserRepo, - useValue: createMock(), - }, - { - provide: CourseRepo, - useValue: createMock(), - }, - { - provide: TaskService, - useValue: createMock(), - }, - { - provide: AuthorizationService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(TaskCardUc); - cardElementRepo = module.get(CardElementRepo); - courseRepo = module.get(CourseRepo); - taskCardRepo = module.get(TaskCardRepo); - userRepo = module.get(UserRepo); - authorizationService = module.get(AuthorizationService); - taskService = module.get(TaskService); - }); - - afterAll(async () => { - await module.close(); - }); - - it('should be defined', () => { - expect(uc).toBeDefined(); - }); - - describe('findOne', () => { - let taskWithStatus: TaskWithStatusVo; - - beforeEach(() => { - user = userFactory.buildWithId(); - taskCard = taskCardFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - taskCardRepo.findById.mockResolvedValue(taskCard); - authorizationService.hasPermission.mockReturnValue(true); - }); - afterEach(() => { - userRepo.findById.mockRestore(); - taskCardRepo.findById.mockRestore(); - authorizationService.hasPermission.mockRestore(); - }); - it('should check for permission to view the TaskCard', async () => { - await uc.findOne(user.id, taskCard.id); - expect(authorizationService.hasPermission).toBeCalledWith(user, taskCard, { - action: Action.read, - requiredPermissions: [Permission.TASK_CARD_VIEW], - }); - }); - it('should throw if user has no permission', async () => { - authorizationService.hasPermission.mockReturnValue(false); - await expect(async () => { - await uc.findOne(user.id, taskCard.id); - }).rejects.toThrow(ForbiddenException); - }); - it('should call taskService', async () => { - await uc.findOne(user.id, taskCard.id); - expect(taskService.find).toBeCalledWith(user.id, taskCard.task.id); - }); - it('should return the taskCard and task', async () => { - const status = taskCard.task.createTeacherStatusForUser(user); - taskWithStatus = new TaskWithStatusVo(taskCard.task, status); - taskService.find.mockResolvedValue(taskWithStatus); - - const result = await uc.findOne(user.id, taskCard.id); - expect(result.card).toEqual(taskCard); - expect(result.taskWithStatusVo).toEqual(taskWithStatus); - }); - }); - - describe('delete', () => { - beforeEach(() => { - user = userFactory.buildWithId(); - taskCard = taskCardFactory.buildWithId(); - - userRepo.findById.mockResolvedValue(user); - taskCardRepo.findById.mockResolvedValue(taskCard); - authorizationService.hasPermission.mockReturnValue(true); - }); - afterEach(() => { - userRepo.findById.mockRestore(); - taskCardRepo.findById.mockRestore(); - authorizationService.hasPermission.mockRestore(); - }); - it('should check for permission to delete (i.e. edit) the TaskCard', async () => { - await uc.delete(user.id, taskCard.id); - expect(authorizationService.hasPermission).toBeCalledWith(user, taskCard, { - action: Action.write, - requiredPermissions: [Permission.TASK_CARD_EDIT], - }); - }); - it('should throw if user has no permission', async () => { - authorizationService.hasPermission.mockReturnValue(false); - await expect(async () => { - await uc.delete(user.id, taskCard.id); - }).rejects.toThrow(ForbiddenException); - }); - it('should delete taskCard', async () => { - await uc.delete(user.id, taskCard.id); - expect(taskCardRepo.delete).toBeCalledWith(taskCard); - }); - it('should return true', async () => { - const result = await uc.delete(user.id, taskCard.id); - expect(result).toEqual(true); - }); - }); - - describe('create', () => { - let taskCardCreateParams: ITaskCardCRUD; - const title = 'text title'; - const richText = ['test rich text 1', 'test rich text 2']; - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - const inThreeDays = new Date(Date.now() + 259200000); - const visibleAtDate = tomorrow; - const dueDate = inTwoDays; - beforeEach(() => { - user = userFactory.buildWithId(); - course = courseFactory.buildWithId({ untilDate: inTwoDays }); - taskCardCreateParams = { - title, - text: [ - new RichText({ content: richText[0], type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: richText[1], type: InputFormat.RICH_TEXT_CK5 }), - ], - courseId: course.id, - visibleAtDate, - dueDate, - }; - - userRepo.findById.mockResolvedValue(user); - courseRepo.findById.mockResolvedValue(course); - taskCardRepo.findById.mockResolvedValue(taskCard); - authorizationService.hasAllPermissions.mockReturnValue(true); - }); - afterEach(() => { - userRepo.findById.mockRestore(); - courseRepo.findById.mockRestore(); - taskCardRepo.findById.mockRestore(); - authorizationService.hasAllPermissions.mockRestore(); - taskService.update.mockRestore(); - }); - it('should check for permission to create (i.e. edit) the TaskCard', async () => { - await uc.create(user.id, taskCardCreateParams); - expect(authorizationService.hasAllPermissions).toBeCalledWith(user, [Permission.TASK_CARD_EDIT]); - }); - it('should throw if user has no permission', async () => { - authorizationService.hasAllPermissions.mockReturnValue(false); - await expect(async () => { - await uc.create(user.id, taskCardCreateParams); - }).rejects.toThrow(ForbiddenException); - }); - it('should check for course permission to create the task related to the taskCard in a course', async () => { - await uc.create(user.id, taskCardCreateParams); - expect(authorizationService.checkPermission).toBeCalledWith(user, course, { - action: Action.write, - requiredPermissions: [], - }); - }); - it('should call task create with task name same like task-card title, courseId, dueDate, availableDate and private as false', async () => { - const taskParams = { - name: taskCardCreateParams.title, - courseId: taskCardCreateParams.courseId, - dueDate: taskCardCreateParams.dueDate, - availableDate: taskCardCreateParams.visibleAtDate, - private: false, - }; - await uc.create(user.id, taskCardCreateParams); - expect(taskService.create).toBeCalledWith(user.id, taskParams); - }); - it('should throw if dueDate is before visibleAtDate', async () => { - const failingTaskCardCreateParams = { - title, - text: [ - new RichText({ content: richText[0], type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: richText[1], type: InputFormat.RICH_TEXT_CK5 }), - ], - visibleAtDate: inTwoDays, - dueDate: tomorrow, - courseId: course.id, - }; - await expect(async () => { - await uc.create(user.id, failingTaskCardCreateParams); - }).rejects.toThrow(ValidationError); - }); - it('should throw if courseUntilDate is before dueDate', async () => { - const failingTaskCardCreateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: inThreeDays, - courseId: course.id, - }; - await expect(async () => { - await uc.create(user.id, failingTaskCardCreateParams); - }).rejects.toThrow(ValidationError); - }); - it('should not throw if the courseUntilDate is chronologically before the dueDate but they share the same calendar date', async () => { - course = courseFactory.buildWithId({ untilDate: new Date(tomorrow.setHours(23, 58)) }); - courseRepo.findById.mockResolvedValue(course); - taskCardCreateParams = { - title, - dueDate: new Date(tomorrow.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.create(user.id, taskCardCreateParams); - expect(card).toBeDefined(); - }); - it('should not throw if the schoolYearEndDate is chronologically before the dueDate but they share the same calendar date', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - const school = schoolFactory.buildWithId({ schoolYear: { endDate: new Date(tomorrow.setHours(23, 58)) } }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - courseRepo.findById.mockResolvedValue(course); - taskCardCreateParams = { - title, - dueDate: new Date(tomorrow.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.create(user.id, taskCardCreateParams); - expect(card).toBeDefined(); - }); - it('should not throw if the nextYearEndDate is chronologically before the dueDate but they share the same calendar date', async () => { - const lastDayOfNextYear = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); - course = courseFactory.buildWithId({ untilDate: undefined }); - user = userFactory.buildWithId(); - const school = schoolFactory.buildWithId({ schoolYear: undefined }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - courseRepo.findById.mockResolvedValue(course); - taskCardCreateParams = { - title, - dueDate: new Date(lastDayOfNextYear.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.create(user.id, taskCardCreateParams); - expect(card).toBeDefined(); - }); - it('should throw if courseEndDate is missing and dueDate after schoolYearEndDate ', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - courseRepo.findById.mockResolvedValue(course); - const school = schoolFactory.buildWithId({ schoolYear: { endDate: inTwoDays } }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - const failingTaskCardCreateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: inThreeDays, - courseId: course.id, - }; - await expect(async () => { - await uc.create(user.id, failingTaskCardCreateParams); - }).rejects.toThrow(ValidationError); - }); - it('should throw if courseEndDate and schoolYearEndDate is missing and dueDate after nextYearEndDate ', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - courseRepo.findById.mockResolvedValue(course); - const school = schoolFactory.buildWithId({ schoolYear: undefined }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - const failingTaskCardCreateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: new Date(Date.now() + 366 * 24 * 60 * 60 * 1000), - courseId: course.id, - }; - await expect(async () => { - await uc.create(user.id, failingTaskCardCreateParams); - }).rejects.toThrow(ValidationError); - }); - it('should create task-card', async () => { - await uc.create(user.id, taskCardCreateParams); - - expect(taskCardRepo.save).toBeCalledWith( - expect.objectContaining({ - cardType: CardType.Task, - draggable: true, - creator: user, - }) - ); - }); - it('should call task update from taskService to add id of task-card to task', async () => { - await uc.create(user.id, taskCardCreateParams); - - expect(taskService.update).toBeCalled(); - }); - it('should return the taskCard and task', async () => { - const result = await uc.create(user.id, taskCardCreateParams); - expect(result.card.task).toEqual(result.taskWithStatusVo.task); - expect(result.card.cardType).toEqual(CardType.Task); - expect(result.card.course?.id).toEqual(taskCardCreateParams.courseId); - expect(result.card.visibleAtDate).toEqual(tomorrow); - expect(result.card.dueDate).toEqual(inTwoDays); - - expect(result.card.cardElements.length).toEqual(2); - expect((result.card.cardElements.getItems()[0] as RichTextCardElement).value).toEqual(richText[0]); - expect((result.card.cardElements.getItems()[1] as RichTextCardElement).value).toEqual(richText[1]); - }); - it('should return the taskCard with default visibleAtDate if params are not given', async () => { - const taskCardCreateDefaultParams = { - title, - text: [ - new RichText({ content: richText[0], type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: richText[1], type: InputFormat.RICH_TEXT_CK5 }), - ], - courseId: course.id, - dueDate: tomorrow, - }; - const result = await uc.create(user.id, taskCardCreateDefaultParams); - const expectedVisibleAtDate = new Date(); - expect(result.card.visibleAtDate).toEqual(expectedVisibleAtDate); - }); - }); - - describe('update', () => { - let taskCardUpdateParams: ITaskCardCRUD; - const title = 'changed text title'; - const richText = ['changed rich text 1', 'changed rich text 2']; - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - const inThreeDays = new Date(Date.now() + 259200000); - const inFourDays = new Date(Date.now() + 345600000); - const visibleAtDate = tomorrow; - const dueDate = inTwoDays; - beforeEach(() => { - user = userFactory.buildWithId(); - course = courseFactory.buildWithId({ untilDate: inFourDays }); - - const originalRichTextCardElements = richTextCardElementFactory.buildList(2); - taskCard = taskCardFactory.buildWithId({ - visibleAtDate, - dueDate, - cardElements: [...originalRichTextCardElements], - }); - - const status = taskCard.task.createTeacherStatusForUser(user); - const taskWithStatusVo = new TaskWithStatusVo(taskCard.task, status); - taskService.update.mockResolvedValue(taskWithStatusVo); - - taskCardUpdateParams = { - id: taskCard.id, - title, - text: [ - new RichText({ content: richText[0], type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: richText[1], type: InputFormat.RICH_TEXT_CK5 }), - ], - visibleAtDate: inTwoDays, - dueDate: inThreeDays, - courseId: course.id, - }; - - userRepo.findById.mockResolvedValue(user); - courseRepo.findById.mockResolvedValue(course); - taskCardRepo.findById.mockResolvedValue(taskCard); - authorizationService.hasPermission.mockReturnValue(true); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - }); - afterEach(() => { - userRepo.findById.mockRestore(); - courseRepo.findById.mockRestore(); - taskCardRepo.findById.mockRestore(); - authorizationService.hasPermission.mockRestore(); - authorizationService.getUserWithPermissions.mockRestore(); - taskService.update.mockRestore(); - }); - it('should check for permission to edit the TaskCard', async () => { - await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(authorizationService.hasPermission).toBeCalledWith(user, taskCard, { - action: Action.write, - requiredPermissions: [Permission.TASK_CARD_EDIT], - }); - }); - it('should throw if user has no permission', async () => { - authorizationService.hasPermission.mockReturnValue(false); - await expect(async () => { - await uc.update(user.id, taskCard.id, taskCardUpdateParams); - }).rejects.toThrow(ForbiddenException); - }); - it('should call task update and with task name same like task-card title, updated courseId and dueDate', async () => { - const taskParams = { - name: taskCardUpdateParams.title, - courseId: taskCardUpdateParams.courseId, - dueDate: taskCardUpdateParams.dueDate, - }; - await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(taskService.update).toBeCalledWith(user.id, taskCard.task.id, taskParams); - }); - it('should throw if dueDate is before visibleAtDate', async () => { - const failingTaskCardUpdateParams = { - id: taskCard.id, - title, - text: [ - new RichText({ content: richText[0], type: InputFormat.RICH_TEXT_CK5 }), - new RichText({ content: richText[1], type: InputFormat.RICH_TEXT_CK5 }), - ], - visibleAtDate: inThreeDays, - dueDate: inTwoDays, - courseId: taskCard.course.id, - }; - await expect(async () => { - await uc.update(user.id, taskCard.id, failingTaskCardUpdateParams); - }).rejects.toThrow(ValidationError); - }); - it('should delete existing card elements and set the new elements', async () => { - const originalCardElements = taskCard.cardElements.getItems(); - const result = await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(cardElementRepo.delete).toBeCalledWith(originalCardElements); - - const updatedCardElements = result.card.cardElements.getItems(); - expect(updatedCardElements).toHaveLength(2); - - const richTextCardElements = updatedCardElements.filter( - (element) => element.cardElementType === CardElementType.RichText - ) as RichTextCardElement[]; - expect(richTextCardElements).toHaveLength(2); - expect(richTextCardElements[0].value).toEqual(richText[0]); - expect(richTextCardElements[1].value).toEqual(richText[1]); - }); - it('should return the taskCard and task', async () => { - const result = await uc.update(user.id, taskCard.id, taskCardUpdateParams); - - expect(result.card.task.id).toEqual(result.taskWithStatusVo.task.id); - expect(result.card.cardType).toEqual(CardType.Task); - expect(result.card.visibleAtDate).toEqual(inTwoDays); - expect(result.card.dueDate).toEqual(inThreeDays); - - expect(result.card.cardElements.length).toEqual(2); - expect((result.card.cardElements.getItems()[0] as RichTextCardElement).value).toEqual(richText[0]); - expect((result.card.cardElements.getItems()[1] as RichTextCardElement).value).toEqual(richText[1]); - }); - it('should throw if courseEndDate is before dueDate ', async () => { - course = courseFactory.buildWithId({ untilDate: tomorrow }); - user = userFactory.buildWithId(); - courseRepo.findById.mockResolvedValue(course); - const failingTaskCardUpdateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: inThreeDays, - courseId: course.id, - }; - await expect(async () => { - await uc.update(user.id, taskCard.id, failingTaskCardUpdateParams); - }).rejects.toThrow(ValidationError); - }); - it('should not throw if the courseUntilDate is chronologically before the dueDate but they share the same calendar date', async () => { - course = courseFactory.buildWithId({ untilDate: new Date(tomorrow.setHours(23, 58)) }); - user = userFactory.buildWithId(); - courseRepo.findById.mockResolvedValue(course); - taskCardUpdateParams = { - title, - dueDate: new Date(tomorrow.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(card.dueDate).toEqual(taskCardUpdateParams.dueDate); - }); - it('should not throw if the schoolYearEndDate is chronologically before the dueDate but they share the same calendar date', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - user = userFactory.buildWithId(); - const school = schoolFactory.buildWithId({ schoolYear: { endDate: new Date(tomorrow.setHours(23, 58)) } }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - courseRepo.findById.mockResolvedValue(course); - taskCardUpdateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: new Date(tomorrow.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(card.dueDate).toEqual(taskCardUpdateParams.dueDate); - }); - it('should not throw if the nextYearEndDate is chronologically before the dueDate but they share the same calendar date', async () => { - const lastDayOfNextYear = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); - course = courseFactory.buildWithId({ untilDate: undefined }); - user = userFactory.buildWithId(); - const school = schoolFactory.buildWithId({ schoolYear: undefined }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - courseRepo.findById.mockResolvedValue(course); - taskCardUpdateParams = { - title, - dueDate: new Date(lastDayOfNextYear.setHours(23, 59)), - courseId: course.id, - }; - const { card } = await uc.update(user.id, taskCard.id, taskCardUpdateParams); - expect(card.dueDate).toEqual(taskCardUpdateParams.dueDate); - }); - it('should throw if courseEndDate is missing and dueDate after schoolYearEnd ', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - courseRepo.findById.mockResolvedValue(course); - const school = schoolFactory.buildWithId({ schoolYear: { endDate: inTwoDays } }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - const failingTaskCardUpdateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: inThreeDays, - courseId: course.id, - }; - await expect(async () => { - await uc.update(user.id, taskCard.id, failingTaskCardUpdateParams); - }).rejects.toThrow(ValidationError); - }); - it('should throw if courseEndDate and schoolYearEndDate is missing and dueDate after nextYearEnd ', async () => { - course = courseFactory.buildWithId({ untilDate: undefined }); - courseRepo.findById.mockResolvedValue(course); - const school = schoolFactory.buildWithId({ schoolYear: undefined }); - const userWithSchool = userFactory.buildWithId({ school }); - userRepo.findById.mockResolvedValue(userWithSchool); - authorizationService.getUserWithPermissions.mockResolvedValue(userWithSchool); - const failingTaskCardUpdateParams = { - title, - visibleAtDate: new Date(Date.now()), - dueDate: new Date(Date.now() + 366 * 24 * 60 * 60 * 1000), - courseId: course.id, - }; - await expect(async () => { - await uc.update(user.id, taskCard.id, failingTaskCardUpdateParams); - }).rejects.toThrow(ValidationError); - }); - }); -}); diff --git a/apps/server/src/modules/task-card/uc/task-card.uc.ts b/apps/server/src/modules/task-card/uc/task-card.uc.ts deleted file mode 100644 index a4771f8b819..00000000000 --- a/apps/server/src/modules/task-card/uc/task-card.uc.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; -import { ValidationError } from '@shared/common/error'; -import { CardType, Course, EntityId, Permission, TaskCard, User } from '@shared/domain'; -import { CardElement, RichTextCardElement } from '@shared/domain/entity/card-element.entity'; -import { ITaskCardProps } from '@shared/domain/entity/task-card.entity'; -import { CardElementRepo, CourseRepo, TaskCardRepo } from '@shared/repo'; -import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; -import { TaskService } from '@src/modules/task/service'; -import { ITaskCardCRUD } from '../interface'; - -@Injectable() -export class TaskCardUc { - constructor( - private taskCardRepo: TaskCardRepo, - private cardElementRepo: CardElementRepo, - private readonly authorizationService: AuthorizationService, - private readonly courseRepo: CourseRepo, - private readonly taskService: TaskService - ) {} - - async create(userId: EntityId, params: ITaskCardCRUD) { - const user = await this.authorizationService.getUserWithPermissions(userId); - if (!this.authorizationService.hasAllPermissions(user, [Permission.TASK_CARD_EDIT])) { - throw new ForbiddenException(); - } - - const course = await this.courseRepo.findById(params.courseId); - this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.write([])); - - this.validateDueDate({ params, course, user }); - - const taskWithStatusVo = await this.createTask(userId, params); - - const cardElements: CardElement[] = []; - - if (params.text) { - const texts = params.text.map((text) => new RichTextCardElement(text)); - cardElements.push(...texts); - } - - const cardParams: ITaskCardProps = { - cardElements, - cardType: CardType.Task, - course, - creator: user, - draggable: true, - task: taskWithStatusVo.task, - visibleAtDate: new Date(), - dueDate: params.dueDate, - title: params.title, - }; - - if (params.visibleAtDate) { - cardParams.visibleAtDate = params.visibleAtDate; - } - - const card = new TaskCard(cardParams); - - await this.taskCardRepo.save(card); - - await this.addTaskCardId(userId, card); - - return { card, taskWithStatusVo }; - } - - async findOne(userId: EntityId, id: EntityId) { - const user = await this.authorizationService.getUserWithPermissions(userId); - const card = await this.taskCardRepo.findById(id); - - if ( - !this.authorizationService.hasPermission( - user, - card, - AuthorizationContextBuilder.read([Permission.TASK_CARD_VIEW]) - ) - ) { - throw new ForbiddenException(); - } - - const taskWithStatusVo = await this.taskService.find(userId, card.task.id); - - return { card, taskWithStatusVo }; - } - - async delete(userId: EntityId, id: EntityId) { - const user = await this.authorizationService.getUserWithPermissions(userId); - const card = await this.taskCardRepo.findById(id); - - if ( - !this.authorizationService.hasPermission( - user, - card, - AuthorizationContextBuilder.write([Permission.TASK_CARD_EDIT]) - ) - ) { - throw new ForbiddenException(); - } - - await this.taskCardRepo.delete(card); - - return true; - } - - async update(userId: EntityId, id: EntityId, params: ITaskCardCRUD) { - const user = await this.authorizationService.getUserWithPermissions(userId); - const card = await this.taskCardRepo.findById(id); - - if ( - !this.authorizationService.hasPermission( - user, - card, - AuthorizationContextBuilder.write([Permission.TASK_CARD_EDIT]) - ) - ) { - throw new ForbiddenException(); - } - - const course = await this.courseRepo.findById(params.courseId); - this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.write([])); - - this.validateDueDate({ params, course, user }); - - const taskWithStatusVo = await this.updateTask(userId, card.task.id, params); - - const cardElements: CardElement[] = []; - card.title = params.title; - card.course = course; - card.dueDate = params.dueDate; - - if (params.text) { - const texts = params.text.map((text) => new RichTextCardElement(text)); - cardElements.push(...texts); - } - - if (params.visibleAtDate) { - card.visibleAtDate = params.visibleAtDate; - } - - await this.replaceCardElements(card, cardElements); - await this.taskCardRepo.save(card); - - return { card, taskWithStatusVo }; - } - - private async createTask(userId: EntityId, params: ITaskCardCRUD) { - const taskParams = { - name: params.title, - courseId: params.courseId, - dueDate: params.dueDate, - availableDate: new Date(), - private: false, - }; - - if (params.visibleAtDate) { - taskParams.availableDate = params.visibleAtDate; - } - - const taskWithStatusVo = await this.taskService.create(userId, taskParams); - - return taskWithStatusVo; - } - - private async addTaskCardId(userId: EntityId, taskCard: TaskCard) { - const taskParams = { - name: taskCard.task.name, - taskCard: taskCard.id, - }; - const taskWithStatusVo = await this.taskService.update(userId, taskCard.task.id, taskParams); - - return taskWithStatusVo; - } - - private async updateTask(userId: EntityId, id: EntityId, params: ITaskCardCRUD) { - const taskParams = { - name: params.title, - courseId: params.courseId, - dueDate: params.dueDate, - }; - const taskWithStatusVo = await this.taskService.update(userId, id, taskParams); - - return taskWithStatusVo; - } - - private async replaceCardElements(taskCard: TaskCard, newCardElements: CardElement[]) { - await this.cardElementRepo.delete(taskCard.cardElements.getItems()); - taskCard.cardElements.set(newCardElements); - - return taskCard; - } - - private validateDueDate(validationObject: { params: ITaskCardCRUD; course: Course; user: User }) { - const { params, course, user } = validationObject; - if (course.untilDate) { - this.checkCourseEndDate(course.untilDate, params.dueDate); - } else if (user.school.schoolYear?.endDate) { - this.checkSchoolYearEndDate(user.school.schoolYear.endDate, params.dueDate); - } else { - this.checkNextYearEndDate(params.dueDate); - } - if (params.visibleAtDate && params.visibleAtDate > params.dueDate) { - throw new ValidationError('Visible at date must be before due date'); - } - } - - private checkSchoolYearEndDate(schoolYearEndDate: Date, dueDate: Date) { - if (this.isFirstDateEarlierThanSecondDate(schoolYearEndDate, dueDate)) { - throw new ValidationError('Due date must be before school year end date'); - } - } - - private checkCourseEndDate(courseEndDate: Date, dueDate: Date) { - if (this.isFirstDateEarlierThanSecondDate(courseEndDate, dueDate)) { - throw new ValidationError('Due date must be before course end date'); - } - } - - private checkNextYearEndDate(dueDate: Date) { - const lastDayOfNextYear = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); - if (this.isFirstDateEarlierThanSecondDate(lastDayOfNextYear, dueDate)) { - throw new ValidationError('Due date must be before end of next year'); - } - } - - private isFirstDateEarlierThanSecondDate(dateToCompare: Date, referenceDate: Date): boolean { - const firstDate = new Date(dateToCompare); - const secondDate = new Date(referenceDate); - return firstDate.setHours(0, 0, 0, 0) < secondDate.setHours(0, 0, 0, 0); - } -} diff --git a/apps/server/src/modules/task/controller/api-test/task-delete.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-delete.api.spec.ts index ba71b7188b8..a85474c7862 100644 --- a/apps/server/src/modules/task/controller/api-test/task-delete.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-delete.api.spec.ts @@ -15,7 +15,6 @@ import { ServerTestModule } from '@src/modules/server'; const createStudent = () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({}, [ - Permission.TASK_CARD_VIEW, Permission.TASK_DASHBOARD_VIEW_V3, Permission.HOMEWORK_VIEW, ]); @@ -64,7 +63,7 @@ describe('Task Controller (API)', () => { teachers: [teacher.user], students: [student.user], }); - const task = taskFactory.isPublished().build({ course, users: [student.user] }); + const task = taskFactory.isPublished().build({ course }); await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, task]); em.clear(); diff --git a/apps/server/src/modules/task/controller/api-test/task-finish.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-finish.api.spec.ts index 2d78c6080e1..43597913adb 100644 --- a/apps/server/src/modules/task/controller/api-test/task-finish.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-finish.api.spec.ts @@ -13,7 +13,6 @@ import { ServerTestModule } from '@src/modules/server'; const createStudent = () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({}, [ - Permission.TASK_CARD_VIEW, Permission.TASK_DASHBOARD_VIEW_V3, Permission.HOMEWORK_VIEW, ]); @@ -59,7 +58,7 @@ describe('Task Controller (API)', () => { teachers: [teacher.user], students: [student.user], }); - const task = taskFactory.build({ course, users: [student.user], finished: [] }); + const task = taskFactory.build({ course, finished: [] }); await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, task]); em.clear(); diff --git a/apps/server/src/modules/task/controller/api-test/task-restore.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-restore.api.spec.ts index 3466c171969..221d53f0f75 100644 --- a/apps/server/src/modules/task/controller/api-test/task-restore.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-restore.api.spec.ts @@ -13,7 +13,6 @@ import { ServerTestModule } from '@src/modules/server'; const createStudent = () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({}, [ - Permission.TASK_CARD_VIEW, Permission.TASK_DASHBOARD_VIEW_V3, Permission.HOMEWORK_VIEW, ]); @@ -59,7 +58,7 @@ describe('Task Controller (API)', () => { teachers: [teacher.user], students: [student.user], }); - const task = taskFactory.build({ course, users: [student.user], finished: [student.user] }); + const task = taskFactory.build({ course, finished: [student.user] }); await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, task]); em.clear(); diff --git a/apps/server/src/modules/task/controller/api-test/task-revert-published.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-revert-published.api.spec.ts index 84c49a166ae..b26555c7c7c 100644 --- a/apps/server/src/modules/task/controller/api-test/task-revert-published.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-revert-published.api.spec.ts @@ -13,7 +13,6 @@ import { ServerTestModule } from '@src/modules/server'; const createStudent = () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({}, [ - Permission.TASK_CARD_VIEW, Permission.TASK_DASHBOARD_VIEW_V3, Permission.HOMEWORK_VIEW, ]); @@ -59,7 +58,7 @@ describe('Task Controller (API)', () => { teachers: [teacher.user], students: [student.user], }); - const task = taskFactory.isPublished().build({ course, users: [student.user] }); + const task = taskFactory.isPublished().build({ course }); await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, task]); em.clear(); diff --git a/apps/server/src/modules/task/controller/api-test/task.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task.api.spec.ts index b446215784d..369216b0d50 100644 --- a/apps/server/src/modules/task/controller/api-test/task.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task.api.spec.ts @@ -1,6 +1,5 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { InputFormat, Permission } from '@shared/domain'; import { @@ -8,25 +7,16 @@ import { UserAndAccountTestFactory, cleanupCollections, courseFactory, - lessonFactory, - mapUserToCurrentUser, - roleFactory, submissionFactory, taskFactory, - userFactory, } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@src/modules/server/server.module'; -import { TaskCreateParams, TaskListResponse, TaskResponse, TaskUpdateParams } from '@src/modules/task/controller/dto'; -import { Request } from 'express'; -import request from 'supertest'; +import { TaskListResponse } from '@src/modules/task/controller/dto'; const tomorrow = new Date(Date.now() + 86400000); const createStudent = () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({}, [ - Permission.TASK_CARD_VIEW, Permission.TASK_DASHBOARD_VIEW_V3, Permission.HOMEWORK_VIEW, ]); @@ -600,496 +590,5 @@ describe('Task Controller (API)', () => { expect(result.total).toEqual(3); }); }); - - describe('when student is assigned to 1 task in course is given', () => { - const setup = async () => { - const teacher = createTeacher(); - const student = createStudent(); - const course = courseFactory.build({ - teachers: [teacher.user], - students: [student.user], - }); - const task = taskFactory.build({ course, users: [student.user] }); - - await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, course, task]); - em.clear(); - - return { student, task, teacher, course }; - }; - - it('should find tasks to which student is assigned', async () => { - const { student, task } = await setup(); - - const response = await (await testApiClient.login(student.account)).get(); - const { data } = response.body as TaskListResponse; - - expect(response.statusCode).toBe(200); - expect(data[0].id).toContain(task.id); - }); - - it('should finds all tasks as teacher (assignment does not change the result)', async () => { - const { teacher } = await setup(); - - const response = await (await testApiClient.login(teacher.account)).get(); - - const { total } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(total).toBe(1); - }); - }); - - describe('when 2 students are in course but one is assigned to task is given', () => { - const setup = async () => { - const teacher = createTeacher(); - const assignedStudent = createStudent(); - const notAssignedStudent = createStudent(); - const course = courseFactory.build({ - teachers: [teacher.user], - students: [assignedStudent.user, notAssignedStudent.user], - }); - const task = taskFactory.build({ course, users: [assignedStudent.user] }); - - await em.persistAndFlush([ - teacher.user, - teacher.account, - assignedStudent.user, - assignedStudent.account, - notAssignedStudent.user, - notAssignedStudent.account, - course, - task, - ]); - em.clear(); - - return { task, course, assignedStudent, notAssignedStudent }; - }; - - it('student does not find tasks, it task has users assigned, but himself is not assigned', async () => { - const { notAssignedStudent } = await setup(); - - const response = await (await testApiClient.login(notAssignedStudent.account)).get(); - - const { total } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(total).toBe(0); - }); - }); - - describe('when task has empty assignment list', () => { - const setup = async () => { - const teacher = createTeacher(); - const student = createStudent(); - const course = courseFactory.build({ - teachers: [teacher.user], - students: [student.user], - }); - const task = taskFactory.build({ course, users: [] }); - - await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, course, task]); - em.clear(); - - return { student, task, teacher, course }; - }; - - it('student finds tasks, if task assignment list is empty', async () => { - const { student, task } = await setup(); - - const response = await (await testApiClient.login(student.account)).get(); - - const { data } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(data[0].id).toContain(task.id); - }); - }); - - describe('when student is assigned to task but not part of course', () => { - const setup = async () => { - const teacher = createTeacher(); - const student = createStudent(); - const course = courseFactory.build({ - teachers: [teacher.user], - students: [], - }); - const task = taskFactory.build({ course, users: [student.user] }); - - await em.persistAndFlush([teacher.user, teacher.account, student.user, student.account, course, task]); - em.clear(); - - return { student, task, teacher, course }; - }; - - it('student does not find tasks to which he is assigned, if he does not belong to course', async () => { - const { student } = await setup(); - - const response = await (await testApiClient.login(student.account)).get(); - - const { total } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(total).toBe(0); - }); - }); - }); - - describe('[GET] /finished', () => { - describe('when task has student assigned to finished task', () => { - const setup = async () => { - const teacher = createTeacher(); - const student = createStudent(); - const notAssignedStudent = createStudent(); - const course = courseFactory.build({ - teachers: [teacher.user], - students: [student.user, notAssignedStudent.user], - }); - const task = taskFactory.build({ course, users: [student.user], finished: [student.user] }); - - await em.persistAndFlush([ - teacher.user, - teacher.account, - student.user, - student.account, - course, - task, - notAssignedStudent.account, - notAssignedStudent.user, - ]); - em.clear(); - - return { student, task, teacher, course, notAssignedStudent }; - }; - - it('should find finished tasks to which student is assigned ', async () => { - const { student, task } = await setup(); - - const response = await (await testApiClient.login(student.account)).get('/finished'); - - const { data } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(data[0].id).toContain(task.id); - }); - - it('student does not find finished tasks, if tasks have users assigned, but himself is not assigned', async () => { - const { notAssignedStudent } = await setup(); - - const response = await (await testApiClient.login(notAssignedStudent.account)).get('/finished'); - - const { total } = response.body as TaskListResponse; - expect(response.statusCode).toBe(200); - expect(total).toBe(0); - }); - }); - }); - - // TODO: refactor - describe('When task-card feature is enabled', () => { - // eslint-disable-next-line @typescript-eslint/no-shadow - let app: INestApplication; - // eslint-disable-next-line @typescript-eslint/no-shadow - let em: EntityManager; - let currentUser: ICurrentUser; - - const setup = (permission) => { - const roles = roleFactory.buildList(1, { - permissions: [permission], - }); - const user = userFactory.build({ roles }); - - return user; - }; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); - - app = module.createNestApplication(); - await app.init(); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(async () => { - await cleanupCollections(em); - Configuration.set('FEATURE_TASK_CARD_ENABLED', true); - }); - - it('GET :id should return existing task', async () => { - const user = setup(Permission.HOMEWORK_VIEW); - const task = taskFactory.build({ name: 'original name', creator: user }); - - await em.persistAndFlush([user, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - const response = await request(app.getHttpServer()) - .get(`/tasks/${task.id}`) - .set('Accept', 'application/json') - .expect(200); - - expect((response.body as TaskResponse).id).toEqual(task.id); - }); - it('POST should create a draft task', async () => { - const teacher = setup(Permission.HOMEWORK_CREATE); - const student = setup(Permission.HOMEWORK_VIEW); - - const course = courseFactory.build({ teachers: [teacher], students: [student] }); - - await em.persistAndFlush([teacher, student, course]); - em.clear(); - - currentUser = mapUserToCurrentUser(teacher); - - const response = await request(app.getHttpServer()) - .post(`/tasks`) - .set('Accept', 'application/json') - .send({ name: 'test', courseId: course.id, usersIds: [student.id] }) - .expect(201); - - expect((response.body as TaskResponse).status.isDraft).toEqual(true); - expect((response.body as TaskResponse).users).toEqual([ - { id: student.id, firstName: student.firstName, lastName: student.lastName }, - ]); - }); - it('PATCH :id should update a task', async () => { - const user = setup(Permission.HOMEWORK_EDIT); - const student1 = setup(Permission.HOMEWORK_VIEW); - const student2 = setup(Permission.HOMEWORK_VIEW); - const course = courseFactory.build({ teachers: [user], students: [student1, student2] }); - const lesson = lessonFactory.build({ course }); - const task = taskFactory.build({ - name: 'original name', - creator: user, - course, - lesson, - users: [student1], - }); - - await em.persistAndFlush([user, course, lesson, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - const updateTaskParams = { - name: 'updated name', - courseId: course.id, - lessonId: lesson.id, - description: '

test

', - availableDate: '2022-10-28T08:28:12.981Z', - dueDate: '2023-10-28T08:28:12.981Z', - usersIds: [student1.id, student2.id], - }; - const response = await request(app.getHttpServer()) - .patch(`/tasks/${task.id}`) - .set('Accept', 'application/json') - .send(updateTaskParams) - .expect(200); - - const responseTask = response.body as TaskResponse; - expect(responseTask.name).toEqual(updateTaskParams.name); - expect(responseTask.description).toEqual({ - content: updateTaskParams.description, - type: InputFormat.RICH_TEXT_CK5, - }); - expect(responseTask.availableDate).toEqual(updateTaskParams.availableDate); - expect(responseTask.dueDate).toEqual(updateTaskParams.dueDate); - expect(responseTask.courseId).toEqual(updateTaskParams.courseId); - expect(responseTask.lessonName).toEqual(lesson.name); - expect(responseTask.users).toEqual([ - { id: student1.id, firstName: student1.firstName, lastName: student1.lastName }, - { id: student2.id, firstName: student2.firstName, lastName: student2.lastName }, - ]); - }); - describe('business logic errors', () => { - it('POST should fail if NOT availableDate < dueDate', async () => { - const user = setup(Permission.HOMEWORK_CREATE); - const course = courseFactory.build({ teachers: [user] }); - - await em.persistAndFlush([user, course]); - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - await request(app.getHttpServer()) - .post(`/tasks`) - .set('Accept', 'application/json') - .send({ name: 'test', availableDate: '2022-11-09T15:06:30.771Z', dueDate: '2021-11-09T15:06:30.771Z' }) - .expect(400); - }); - it('POST should fail if users do not belong to course', async () => { - const teacher = setup(Permission.HOMEWORK_CREATE); - const student1 = setup(Permission.HOMEWORK_VIEW); - const student2 = setup(Permission.HOMEWORK_VIEW); - const course = courseFactory.build({ teachers: [teacher], students: [student1] }); - - await em.persistAndFlush([course, teacher, student1, student2]); - em.clear(); - - currentUser = mapUserToCurrentUser(teacher); - - const taskCreateParams: TaskCreateParams = { name: 'test', courseId: course.id, usersIds: [student2.id] }; - await request(app.getHttpServer()) - .post('/tasks') - .set('Accept', 'application/json') - .send(taskCreateParams) - .expect(403); - }); - it('PATCH :id should fail if NOT availableDate < dueDate', async () => { - const user = setup(Permission.HOMEWORK_EDIT); - const task = taskFactory.build({ name: 'original name', creator: user }); - - await em.persistAndFlush([user, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - const updateTaskParams = { - name: 'updated name', - description: '

test

', - availableDate: '2022-10-28T08:28:12.981Z', - dueDate: '2021-10-28T08:28:12.981Z', - }; - await request(app.getHttpServer()) - .patch(`/tasks/${task.id}`) - .set('Accept', 'application/json') - .send(updateTaskParams) - .expect(400); - }); - it('PATCH should fail if users do not belong to course', async () => { - const teacher = setup(Permission.HOMEWORK_CREATE); - const student1 = setup(Permission.HOMEWORK_VIEW); - const student2 = setup(Permission.HOMEWORK_VIEW); - const course = courseFactory.build({ teachers: [teacher], students: [student1] }); - const task = taskFactory.build({ name: 'original name', creator: teacher, course, users: [student1] }); - - await em.persistAndFlush([teacher, student1, student2, course, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(teacher); - - const taskUpdateParams: TaskUpdateParams = { - name: 'test', - courseId: course.id, - usersIds: [student1.id, student2.id], - }; - - await request(app.getHttpServer()) - .patch(`/tasks/${task.id}`) - .set('Accept', 'application/json') - .send(taskUpdateParams) - .expect(403); - }); - }); - }); - - // TODO: refactor - describe('When task-card feature is not enabled', () => { - // eslint-disable-next-line @typescript-eslint/no-shadow - let app: INestApplication; - // eslint-disable-next-line @typescript-eslint/no-shadow - let em: EntityManager; - let currentUser: ICurrentUser; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); - - app = module.createNestApplication(); - await app.init(); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(async () => { - await cleanupCollections(em); - Configuration.set('FEATURE_TASK_CARD_ENABLED', false); - }); - - const setup = () => { - const roles = roleFactory.buildList(1, { - permissions: [Permission.TASK_DASHBOARD_VIEW_V3], - }); - const user = userFactory.build({ roles }); - - return user; - }; - - it('create task should throw', async () => { - const teacher = setup(); - const course = courseFactory.build({ - teachers: [teacher], - }); - - await em.persistAndFlush([teacher, course]); - em.clear(); - - currentUser = mapUserToCurrentUser(teacher); - - const params = { name: 'test', courseId: course.id }; - await request(app.getHttpServer()).post(`/tasks`).set('Accept', 'application/json').send(params).expect(501); - }); - - it('Find task should throw', async () => { - const student = setup(); - const course = courseFactory.build({ - students: [student], - }); - const teacher = userFactory.build(); - const task = taskFactory.build({ creator: teacher, course, finished: [teacher, student] }); - - await em.persistAndFlush([student, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(student); - - await request(app.getHttpServer()).get(`/tasks/${task.id}`).set('Accept', 'application/json').expect(501); - }); - - it('Update task should throw', async () => { - const student = setup(); - const course = courseFactory.build({ - students: [student], - }); - const teacher = userFactory.build(); - const task = taskFactory.build({ creator: teacher, course, finished: [teacher, student] }); - - await em.persistAndFlush([student, task]); - em.clear(); - - currentUser = mapUserToCurrentUser(student); - const params = { - courseId: course.id, - name: 'test', - }; - await request(app.getHttpServer()) - .patch(`/tasks/${task.id}`) - .set('Accept', 'application/json') - .send(params) - .expect(501); - }); }); }); diff --git a/apps/server/src/modules/task/controller/dto/task-create.params.ts b/apps/server/src/modules/task/controller/dto/task-create.params.ts index dd2c4753b02..0fa020ad779 100644 --- a/apps/server/src/modules/task/controller/dto/task-create.params.ts +++ b/apps/server/src/modules/task/controller/dto/task-create.params.ts @@ -15,18 +15,6 @@ export class TaskCreateParams implements ITaskCreate { }) courseId?: string; - @IsString({ each: true }) - @IsMongoId({ each: true }) - @IsOptional() - @ApiPropertyOptional({ - description: 'List of users ids, which belong to course. This restricts access to the task.', - required: false, - nullable: true, - pattern: '[a-f0-9]{24}', - type: [String], - }) - usersIds?: string[]; - @IsString() @IsMongoId() @IsOptional() diff --git a/apps/server/src/modules/task/controller/dto/task-update.params.ts b/apps/server/src/modules/task/controller/dto/task-update.params.ts index 737e25316aa..5d906ca078c 100644 --- a/apps/server/src/modules/task/controller/dto/task-update.params.ts +++ b/apps/server/src/modules/task/controller/dto/task-update.params.ts @@ -15,18 +15,6 @@ export class TaskUpdateParams implements ITaskUpdate { }) courseId?: string; - @IsString({ each: true }) - @IsMongoId({ each: true }) - @IsOptional() - @ApiPropertyOptional({ - description: 'List of users ids, which belong to course. This restricts access to the task.', - required: false, - nullable: true, - pattern: '[a-f0-9]{24}', - type: [String], - }) - usersIds?: string[]; - @IsString() @IsMongoId() @IsOptional() diff --git a/apps/server/src/modules/task/controller/dto/task.response.ts b/apps/server/src/modules/task/controller/dto/task.response.ts index 914729e08dc..102d7f8efbe 100644 --- a/apps/server/src/modules/task/controller/dto/task.response.ts +++ b/apps/server/src/modules/task/controller/dto/task.response.ts @@ -1,13 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DecodeHtmlEntities, PaginationResponse } from '@shared/controller'; -import { RichText, UsersList } from '@shared/domain'; +import { RichText } from '@shared/domain'; import { TaskStatusResponse } from './task-status.response'; /** * DTO for returning a task document via api. */ export class TaskResponse { - constructor({ id, name, courseName, courseId, users, createdAt, updatedAt, status }: TaskResponse) { + constructor({ id, name, courseName, courseId, createdAt, updatedAt, status }: TaskResponse) { this.id = id; this.name = name; this.courseName = courseName; @@ -16,17 +16,11 @@ export class TaskResponse { this.updatedAt = updatedAt; this.lessonHidden = false; this.status = status; - this.users = users; } @ApiProperty() id: string; - @ApiProperty({ - type: [UsersList], - }) - users?: UsersList[]; - @ApiProperty() @DecodeHtmlEntities() name: string; @@ -47,9 +41,6 @@ export class TaskResponse { @ApiProperty() courseId: string = '' as string; - @ApiPropertyOptional() - taskCardId?: string; - @ApiPropertyOptional({ description: 'Task description object, with props content: string and type: input format types', type: RichText, diff --git a/apps/server/src/modules/task/controller/task.controller.ts b/apps/server/src/modules/task/controller/task.controller.ts index 0897ee082da..b62400d0426 100644 --- a/apps/server/src/modules/task/controller/task.controller.ts +++ b/apps/server/src/modules/task/controller/task.controller.ts @@ -1,5 +1,4 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; -import { Body, Controller, Delete, Get, NotImplementedException, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { RequestTimeout } from '@shared/common'; import { PaginationParams } from '@shared/controller/'; @@ -10,7 +9,7 @@ import { serverConfig } from '@src/modules/server/server.config'; import { TaskMapper } from '../mapper'; import { TaskCopyUC } from '../uc/task-copy.uc'; import { TaskUC } from '../uc/task.uc'; -import { TaskCreateParams, TaskListResponse, TaskResponse, TaskUpdateParams, TaskUrlParams } from './dto'; +import { TaskListResponse, TaskResponse, TaskUrlParams } from './dto'; import { TaskCopyApiParams } from './dto/task-copy.params'; @ApiTags('Task') @@ -103,50 +102,4 @@ export class TaskController { return result; } - - @Post() - async create(@Body() params: TaskCreateParams, @CurrentUser() currentUser: ICurrentUser): Promise { - this.FeatureTaskCardEnabled(); - - const taskWithSatusVo = await this.taskUc.create(currentUser.userId, TaskMapper.mapTaskCreateToDomain(params)); - - const response = TaskMapper.mapToResponse(taskWithSatusVo); - return response; - } - - @Patch(':taskId') - async update( - @Param() urlParams: TaskUrlParams, - @Body() params: TaskUpdateParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - this.FeatureTaskCardEnabled(); - - const taskWithSatusVo = await this.taskUc.update( - currentUser.userId, - urlParams.taskId, - TaskMapper.mapTaskUpdateToDomain(params) - ); - - const response = TaskMapper.mapToResponse(taskWithSatusVo); - - return response; - } - - @Get(':taskId') - async findTask(@Param() urlParams: TaskUrlParams, @CurrentUser() currentUser: ICurrentUser): Promise { - this.FeatureTaskCardEnabled(); - - const taskWithSatusVo = await this.taskUc.find(currentUser.userId, urlParams.taskId); - - const response = TaskMapper.mapToResponse(taskWithSatusVo); - return response; - } - - private FeatureTaskCardEnabled() { - const enabled = Configuration.get('FEATURE_TASK_CARD_ENABLED') as boolean; - if (!enabled) { - throw new NotImplementedException('Feature not enabled'); - } - } } diff --git a/apps/server/src/modules/task/mapper/task.mapper.spec.ts b/apps/server/src/modules/task/mapper/task.mapper.spec.ts index fdf76784ce9..f53d383e07e 100644 --- a/apps/server/src/modules/task/mapper/task.mapper.spec.ts +++ b/apps/server/src/modules/task/mapper/task.mapper.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { InputFormat, ITaskStatus, ITaskUpdate, Task, TaskParentDescriptions, UsersList } from '@shared/domain'; +import { InputFormat, ITaskStatus, ITaskUpdate, Task, TaskParentDescriptions } from '@shared/domain'; import { setupEntities, taskFactory } from '@shared/testing'; import { TaskCreateParams, TaskResponse, TaskStatusResponse, TaskUpdateParams } from '../controller/dto'; import { TaskMapper } from './task.mapper'; @@ -7,8 +7,7 @@ import { TaskMapper } from './task.mapper'; const createExpectedResponse = ( task: Task, status: ITaskStatus, - descriptions: TaskParentDescriptions, - usersList: UsersList[] + descriptions: TaskParentDescriptions ): TaskResponse => { const expectedStatus = Object.create(TaskStatusResponse.prototype) as TaskStatusResponse; expectedStatus.graded = status.graded; @@ -29,9 +28,6 @@ const createExpectedResponse = ( type: task.descriptionInputFormat || InputFormat.RICH_TEXT_CK4, }; } - if (task.taskCard) { - expected.taskCardId = task.taskCard; - } expected.dueDate = task.dueDate; expected.updatedAt = task.updatedAt; expected.status = expectedStatus; @@ -41,7 +37,6 @@ const createExpectedResponse = ( expected.displayColor = descriptions.color; expected.lessonName = descriptions.lessonName; expected.lessonHidden = descriptions.lessonHidden; - expected.users = usersList; return expected; }; @@ -52,9 +47,8 @@ describe('task.mapper', () => { }); describe('mapToResponse', () => { - it('should map task with status, task card and description values', () => { + it('should map task with status and description values', () => { const task = taskFactory.buildWithId({ availableDate: new Date(), dueDate: new Date() }); - task.taskCard = 'task card ID #1'; const descriptions: TaskParentDescriptions = { courseName: 'course #1', @@ -66,20 +60,6 @@ describe('task.mapper', () => { const spyParent = jest.spyOn(task, 'getParentData').mockReturnValue(descriptions); - const usersList: UsersList[] = [ - { - id: 'user ID #1', - firstName: 'user', - lastName: 'name #1', - }, - { - id: 'user ID #2', - firstName: 'user', - lastName: 'name #2', - }, - ]; - const spyUsers = jest.spyOn(task, 'getUsersList').mockReturnValue(usersList); - const status = { graded: 0, maxSubmissions: 0, @@ -90,10 +70,9 @@ describe('task.mapper', () => { }; const result = TaskMapper.mapToResponse({ task, status }); - const expected = createExpectedResponse(task, status, descriptions, usersList); + const expected = createExpectedResponse(task, status, descriptions); expect(spyParent).toHaveBeenCalled(); - expect(spyUsers).toHaveBeenCalled(); expect(result).toStrictEqual(expected); }); }); @@ -106,7 +85,6 @@ describe('task.mapper', () => { description: 'test', dueDate: new Date('2023-05-28T08:00:00.000+00:00'), availableDate: new Date('2022-05-28T08:00:00.000+00:00'), - usersIds: [new ObjectId().toHexString()], }; const result = TaskMapper.mapTaskUpdateToDomain(params); @@ -118,7 +96,6 @@ describe('task.mapper', () => { descriptionInputFormat: InputFormat.RICH_TEXT_CK5, dueDate: params.dueDate, availableDate: params.availableDate, - usersIds: params.usersIds, }; expect(result).toStrictEqual(expected); }); @@ -132,7 +109,6 @@ describe('task.mapper', () => { description: 'test', dueDate: new Date('2023-05-28T08:00:00.000+00:00'), availableDate: new Date('2022-05-28T08:00:00.000+00:00'), - usersIds: [new ObjectId().toHexString()], }; const result = TaskMapper.mapTaskCreateToDomain(params); @@ -144,7 +120,6 @@ describe('task.mapper', () => { descriptionInputFormat: InputFormat.RICH_TEXT_CK5, dueDate: params.dueDate, availableDate: params.availableDate, - usersIds: params.usersIds, }; expect(result).toStrictEqual(expected); }); diff --git a/apps/server/src/modules/task/mapper/task.mapper.ts b/apps/server/src/modules/task/mapper/task.mapper.ts index 399a0d6fcc6..a8db8e59af4 100644 --- a/apps/server/src/modules/task/mapper/task.mapper.ts +++ b/apps/server/src/modules/task/mapper/task.mapper.ts @@ -24,9 +24,6 @@ export class TaskMapper { type: task.descriptionInputFormat || InputFormat.RICH_TEXT_CK4, }); } - if (task.taskCard) { - dto.taskCardId = task.taskCard; - } dto.availableDate = task.availableDate; dto.dueDate = task.dueDate; @@ -36,10 +33,6 @@ export class TaskMapper { } dto.lessonHidden = taskDesc.lessonHidden; - if (task.users) { - dto.users = task.getUsersList(); - } - return dto; } @@ -51,7 +44,6 @@ export class TaskMapper { description: params.description, availableDate: params.availableDate, dueDate: params.dueDate, - usersIds: params.usersIds, }; if (params.description) { dto.descriptionInputFormat = InputFormat.RICH_TEXT_CK5; @@ -67,7 +59,6 @@ export class TaskMapper { description: params.description, availableDate: params.availableDate, dueDate: params.dueDate, - usersIds: params.usersIds, }; if (params.description) { dto.descriptionInputFormat = InputFormat.RICH_TEXT_CK5; diff --git a/apps/server/src/modules/task/service/task.service.spec.ts b/apps/server/src/modules/task/service/task.service.spec.ts index de56515ec61..3183337a540 100644 --- a/apps/server/src/modules/task/service/task.service.spec.ts +++ b/apps/server/src/modules/task/service/task.service.spec.ts @@ -1,29 +1,11 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; - import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ValidationError } from '@shared/common'; -import { Course, ITaskUpdate, Permission, Task, TaskWithStatusVo, User } from '@shared/domain'; -import { CourseRepo, LessonRepo, TaskRepo, UserRepo } from '@shared/repo'; -import { - courseFactory, - lessonFactory, - setupEntities, - submissionFactory, - taskFactory, - userFactory, -} from '@shared/testing'; -import { Action, AuthorizationService } from '@src/modules'; +import { TaskRepo } from '@shared/repo'; +import { setupEntities, submissionFactory, taskFactory } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; import { SubmissionService } from './submission.service'; import { TaskService } from './task.service'; -let userRepo: DeepMocked; -let courseRepo: DeepMocked; -let lessonRepo: DeepMocked; -let authorizationService: DeepMocked; - describe('TaskService', () => { let module: TestingModule; let taskRepo: DeepMocked; @@ -39,13 +21,6 @@ describe('TaskService', () => { provide: TaskRepo, useValue: createMock(), }, - { provide: AuthorizationService, useValue: createMock() }, - { provide: CourseRepo, useValue: createMock() }, - { provide: LessonRepo, useValue: createMock() }, - { - provide: UserRepo, - useValue: createMock(), - }, { provide: SubmissionService, useValue: createMock(), @@ -60,10 +35,6 @@ describe('TaskService', () => { taskRepo = module.get(TaskRepo); taskService = module.get(TaskService); submissionService = module.get(SubmissionService); - userRepo = module.get(UserRepo); - courseRepo = module.get(CourseRepo); - lessonRepo = module.get(LessonRepo); - authorizationService = module.get(AuthorizationService); fileStorageClientAdapterService = module.get(FilesStorageClientAdapterService); await setupEntities(); @@ -78,36 +49,13 @@ describe('TaskService', () => { }); describe('findBySingleParent', () => { - const setup = () => { + it('should call findBySingleParent from task repo', async () => { const courseId = 'courseId'; - const creatorId = 'user-id'; - const user = userFactory.buildWithId(); + const userId = 'user-id'; + taskRepo.findBySingleParent.mockResolvedValueOnce([[], 0]); - return { courseId, creatorId, user }; - }; - - it('should call authorization service', async () => { - const { creatorId, courseId, user } = setup(); - await taskService.findBySingleParent(creatorId, courseId); - expect(authorizationService.hasAllPermissions).toBeCalledWith(user, [Permission.TASK_DASHBOARD_TEACHER_VIEW_V3]); - }); - describe(' when user has TASK_DASHBOARD_TEACHER_VIEW_V3 permission', () => { - it('should call repo without filter for userId', async () => { - const { creatorId, courseId } = setup(); - authorizationService.hasAllPermissions.mockReturnValueOnce(true); - await taskService.findBySingleParent(creatorId, courseId); - expect(taskRepo.findBySingleParent).toBeCalledWith(creatorId, courseId, {}, undefined); - }); - }); - describe('when user has no TASK_DASHBOARD_TEACHER_VIEW_V3 permission', () => { - it('should call repo with filter for userId ', async () => { - const { courseId, user } = setup(); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - taskRepo.findBySingleParent.mockResolvedValueOnce([[], 0]); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - await taskService.findBySingleParent(user.id, courseId); - expect(taskRepo.findBySingleParent).toBeCalledWith(user.id, courseId, { userId: user.id }, undefined); - }); + await expect(taskService.findBySingleParent(userId, courseId)).resolves.toEqual([[], 0]); + expect(taskRepo.findBySingleParent).toBeCalledWith(userId, courseId, undefined, undefined); }); }); @@ -154,378 +102,4 @@ describe('TaskService', () => { expect(taskRepo.delete).toBeCalledWith(task); }); }); - - describe('Single task', () => { - beforeEach(() => { - jest.spyOn(Configuration, 'get').mockImplementation((config: string) => { - if (config === 'FEATURE_TASK_CARD_ENABLED') { - return true; - } - return null; - }); - }); - - describe('create task', () => { - let course: Course; - let user: User; - beforeEach(() => { - user = userFactory.buildWithId(); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - course = courseFactory.buildWithId({ teachers: [user] }); - courseRepo.findById.mockResolvedValue(course); - taskRepo.save.mockResolvedValue(); - authorizationService.hasAllPermissions.mockReturnValue(true); - }); - afterEach(() => { - courseRepo.findById.mockRestore(); - taskRepo.save.mockRestore(); - authorizationService.hasOneOfPermissions.mockRestore(); - authorizationService.getUserWithPermissions.mockRestore(); - }); - - it('should throw if availableDate is not before dueDate', async () => { - const availableDate = new Date('2023-01-12T00:00:00'); - const dueDate = new Date('2023-01-11T00:00:00'); - const params = { name: 'test', availableDate, dueDate }; - await expect(async () => { - await taskService.create(user.id, params); - }).rejects.toThrow(ValidationError); - }); - it('should check for permission to create the task', async () => { - await taskService.create(user.id, { name: 'test' }); - expect(authorizationService.hasAllPermissions).toBeCalledWith(user, [Permission.HOMEWORK_CREATE]); - }); - it('should throw if the user has no permission', async () => { - authorizationService.hasAllPermissions.mockReturnValue(false); - await expect(async () => { - await taskService.create(user.id, { name: 'test' }); - }).rejects.toThrow(UnauthorizedException); - authorizationService.hasAllPermissions.mockRestore(); - }); - it('should check for course permission to create the task in a course', async () => { - await taskService.create(user.id, { name: 'test', courseId: course.id }); - expect(authorizationService.checkPermission).toBeCalledWith(user, course, { - action: Action.write, - requiredPermissions: [], - }); - }); - it('should check for lesson permission to create the task in a lesson', async () => { - const lesson = lessonFactory.buildWithId({ course }); - lessonRepo.findById.mockResolvedValue(lesson); - await taskService.create(user.id, { name: 'test', courseId: course.id, lessonId: lesson.id }); - expect(authorizationService.checkPermission).toBeCalledWith(user, lesson, { - action: Action.write, - requiredPermissions: [], - }); - }); - it('should throw if lesson does not belong to course', async () => { - const lesson = lessonFactory.buildWithId(); - lessonRepo.findById.mockResolvedValue(lesson); - await expect(async () => { - await taskService.create(user.id, { name: 'test', courseId: course.id, lessonId: lesson.id }); - }).rejects.toThrow(ForbiddenException); - - lessonRepo.findById.mockRestore(); - }); - it('should throw if not all users do not belong to course', async () => { - course = courseFactory.studentsWithId(2).buildWithId(); - const someUser = userFactory.buildWithId(); - - await expect(async () => { - await taskService.create(user.id, { name: 'test', courseId: course.id, usersIds: [someUser.id] }); - }).rejects.toThrow(ForbiddenException); - }); - it('should save the task', async () => { - const taskMock = { - name: 'test', - creator: user, - }; - await taskService.create(user.id, { name: 'test' }); - expect(taskRepo.save).toHaveBeenCalledWith(expect.objectContaining({ ...taskMock })); - }); - it('should save the task with course', async () => { - const taskMock = { - name: 'test', - course, - }; - await taskService.create(user.id, { name: 'test', courseId: course.id }); - expect(taskRepo.save).toHaveBeenCalledWith(expect.objectContaining({ ...taskMock })); - }); - it('should save the task with course and lesson', async () => { - const lesson = lessonFactory.buildWithId({ course }); - lessonRepo.findById.mockResolvedValue(lesson); - const taskMock = { - name: 'test', - course, - lesson, - }; - await taskService.create(user.id, { name: 'test', courseId: course.id, lessonId: lesson.id }); - expect(taskRepo.save).toHaveBeenCalledWith(expect.objectContaining({ ...taskMock })); - - lessonRepo.findById.mockRestore(); - }); - it('should save the task with course and assigned users', async () => { - const student1 = userFactory.buildWithId(); - const student2 = userFactory.buildWithId(); - const course2 = courseFactory.buildWithId({ teachers: [user], students: [student1, student2] }); - courseRepo.findById.mockResolvedValue(course2); - userRepo.findById.mockImplementation((id) => { - if (id === student1.id) { - return Promise.resolve(student1); - } - if (id === student2.id) { - return Promise.resolve(student2); - } - return Promise.resolve(user); - }); - const taskWithStatusVo: TaskWithStatusVo = await taskService.create(user.id, { - name: 'test', - courseId: course2.id, - usersIds: [student1.id], - }); - expect(taskRepo.save).toBeCalled(); - expect(taskWithStatusVo.task.users.getItems()).toEqual([student1]); - }); - it('should return the task and its status', async () => { - const taskMock = { - name: 'test', - creator: user, - course, - }; - authorizationService.hasPermission.mockReturnValue(true); - const result = await taskService.create(user.id, { name: 'test', courseId: course.id }); - expect(result.task).toEqual(expect.objectContaining(taskMock)); - expect(result.status.isDraft).toEqual(true); - }); - }); - describe('update task', () => { - let course: Course; - let task: Task; - let user: User; - beforeEach(() => { - user = userFactory.buildWithId(); - course = courseFactory.buildWithId({ teachers: [user] }); - - task = taskFactory.build({ course }); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - courseRepo.findById.mockResolvedValue(course); - - taskRepo.findById.mockResolvedValue(task); - taskRepo.save.mockResolvedValue(); - }); - - afterEach(() => { - courseRepo.findById.mockRestore(); - taskRepo.save.mockRestore(); - taskRepo.findById.mockRestore(); - authorizationService.getUserWithPermissions.mockRestore(); - }); - it('should throw if availableDate is not before dueDate', async () => { - const availableDate = new Date('2023-01-12T00:00:00'); - const dueDate = new Date('2023-01-11T00:00:00'); - const params = { name: 'test', availableDate, dueDate }; - await expect(async () => { - await taskService.update(user.id, task.id, params); - }).rejects.toThrow(ValidationError); - }); - it('should check for permission to update the task', async () => { - const params = { - name: 'test', - }; - await taskService.update(user.id, task.id, params); - expect(authorizationService.checkPermission).toBeCalledWith(user, task, { - action: Action.write, - requiredPermissions: [Permission.HOMEWORK_EDIT], - }); - }); - it('should check authorization for course', async () => { - const params = { - name: 'test', - courseId: course.id, - }; - await taskService.update(user.id, task.id, params); - expect(authorizationService.checkPermission).toBeCalledWith(user, course, { - action: Action.write, - requiredPermissions: [], - }); - }); - it('should save the task with course', async () => { - const params = { - name: 'test', - courseId: course.id, - }; - await taskService.update(user.id, task.id, params); - expect(taskRepo.save).toHaveBeenCalledWith({ ...task, name: params.name }); - }); - it('should save the task with course and lesson', async () => { - const lesson = lessonFactory.buildWithId({ course }); - lessonRepo.findById.mockResolvedValue(lesson); - const params = { - name: 'test', - courseId: course.id, - lessonId: lesson.id, - }; - await taskService.update(user.id, task.id, params); - expect(taskRepo.save).toHaveBeenCalledWith({ ...task, name: params.name, lessonId: lesson.id }); - - lessonRepo.findById.mockRestore(); - }); - describe('when remove is true', () => { - it('should save the task and remove course', async () => { - const params = { - name: 'test', - }; - const taskWithStatusVo: TaskWithStatusVo = await taskService.update(user.id, task.id, params, true); - expect(taskRepo.save).toHaveBeenCalledWith({ ...task, name: params.name }); - expect(taskWithStatusVo.task.course).toBe(undefined); - }); - it('should save the task with course and remove lesson', async () => { - const lesson = lessonFactory.buildWithId({ course }); - lessonRepo.findById.mockResolvedValue(lesson); - task = taskFactory.build({ course, lesson }); - taskRepo.findById.mockResolvedValue(task); - - const params = { - name: 'test', - courseId: course.id, - }; - const taskWithStatusVo: TaskWithStatusVo = await taskService.update(user.id, task.id, params, true); - expect(taskRepo.save).toHaveBeenCalledWith({ ...task, name: params.name }); - expect(taskWithStatusVo.task.lesson).toBe(undefined); - - lessonRepo.findById.mockRestore(); - }); - it('should save the task with course and remove users', async () => { - const student1 = userFactory.buildWithId(); - const student2 = userFactory.buildWithId(); - - const courseWithStudents = courseFactory.buildWithId({ teachers: [user], students: [student1, student2] }); - courseRepo.findById.mockResolvedValue(courseWithStudents); - - userRepo.findById.mockImplementation((id) => { - if (id === student1.id) { - return Promise.resolve(student1); - } - if (id === student2.id) { - return Promise.resolve(student2); - } - return Promise.resolve(user); - }); - - const task2 = taskFactory.build({ course, users: [student1, student2] }); - taskRepo.findById.mockResolvedValue(task2); - - const params = { - name: 'test', - courseId: course.id, - }; - - const taskWithStatusVo: TaskWithStatusVo = await taskService.update(user.id, task2.id, params, true); - - expect(taskRepo.save).toHaveBeenCalled(); - expect(taskWithStatusVo.task.users.getItems()).toStrictEqual([]); - }); - }); - it('should throw if not all users do not belong to course', async () => { - const someUser = userFactory.buildWithId(); - const params = { - name: 'test', - courseId: course.id, - usersIds: [someUser.id], - }; - - await expect(async () => { - await taskService.update(user.id, task.id, params); - }).rejects.toThrow(ForbiddenException); - }); - it('should throw if lesson does not belong to course', async () => { - const lesson = lessonFactory.buildWithId(); - lessonRepo.findById.mockResolvedValue(lesson); - const params = { - name: 'test', - courseId: course.id, - lessonId: lesson.id, - }; - await expect(async () => { - await taskService.update(user.id, task.id, params); - }).rejects.toThrow(ForbiddenException); - - lessonRepo.findById.mockRestore(); - }); - it('should return the updated task', async () => { - const params = { - name: 'test', - courseId: course.id, - }; - const result = await taskService.update(user.id, task.id, params); - expect(result.task).toEqual({ ...task, name: params.name }); - expect(result.status).toBeDefined(); - }); - it('should return the task with course and assigned users', async () => { - const student1 = userFactory.buildWithId(); - const student2 = userFactory.buildWithId(); - const courseWithStudents = courseFactory.buildWithId({ teachers: [user], students: [student1, student2] }); - courseRepo.findById.mockResolvedValue(courseWithStudents); - userRepo.findById.mockImplementation((id) => { - if (id === student1.id) { - return Promise.resolve(student1); - } - if (id === student2.id) { - return Promise.resolve(student2); - } - return Promise.resolve(user); - }); - - task = taskFactory.build({ course: courseWithStudents, users: [student1] }); - - const taskParams: ITaskUpdate = { - name: 'test', - courseId: courseWithStudents.id, - usersIds: [student2.id], - }; - const taskWithStatusVo: TaskWithStatusVo = await taskService.update(user.id, task.id, taskParams); - expect(taskRepo.save).toBeCalled(); - expect(taskWithStatusVo.task.users.getItems()).toEqual([student2]); - }); - }); - describe('find task', () => { - let task: Task; - let user: User; - beforeEach(() => { - user = userFactory.buildWithId(); - task = taskFactory.build(); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - taskRepo.findById.mockResolvedValue(task); - }); - afterEach(() => { - authorizationService.getUserWithPermissions.mockRestore(); - taskRepo.findById.mockRestore(); - }); - it('should check for permission to view the task', async () => { - await taskService.find(user.id, task.id); - expect(authorizationService.checkPermission).toBeCalledWith(user, task, { - action: Action.read, - requiredPermissions: [Permission.HOMEWORK_VIEW], - }); - }); - it('should check also user permission to edit task', async () => { - await taskService.find(user.id, task.id); - expect(authorizationService.hasOneOfPermissions).toBeCalledWith(user, [Permission.HOMEWORK_EDIT]); - }); - it('should return the task with its status for student if user has only view permission', async () => { - authorizationService.hasOneOfPermissions.mockReturnValue(false); - - const result = await taskService.find(user.id, task.id); - expect(result.task).toEqual(task); - expect(result.status).toBeDefined(); - }); - it('should return the task with its status for student if user has only edit permission', async () => { - authorizationService.hasOneOfPermissions.mockReturnValue(true); - - const result = await taskService.find(user.id, task.id); - expect(result.task).toEqual(task); - expect(result.status).toBeDefined(); - }); - }); - }); }); diff --git a/apps/server/src/modules/task/service/task.service.ts b/apps/server/src/modules/task/service/task.service.ts index 0fe85a73c80..c7e433ae73b 100644 --- a/apps/server/src/modules/task/service/task.service.ts +++ b/apps/server/src/modules/task/service/task.service.ts @@ -1,18 +1,6 @@ -import { ForbiddenException, forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import { ValidationError } from '@shared/common'; -import { - Counted, - EntityId, - IFindOptions, - ITaskCreate, - ITaskProperties, - ITaskUpdate, - Permission, - Task, - TaskWithStatusVo, -} from '@shared/domain'; -import { CourseRepo, LessonRepo, TaskRepo, UserRepo } from '@shared/repo'; -import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { Counted, EntityId, IFindOptions, Task } from '@shared/domain'; +import { TaskRepo } from '@shared/repo'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; import { SubmissionService } from './submission.service'; @@ -20,11 +8,6 @@ import { SubmissionService } from './submission.service'; export class TaskService { constructor( private readonly taskRepo: TaskRepo, - private readonly userRepo: UserRepo, - @Inject(forwardRef(() => AuthorizationService)) - private readonly authorizationService: AuthorizationService, - private readonly courseRepo: CourseRepo, - private readonly lessonRepo: LessonRepo, private readonly submissionService: SubmissionService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService ) {} @@ -35,13 +18,7 @@ export class TaskService { filters?: { draft?: boolean; noFutureAvailableDate?: boolean }, options?: IFindOptions ): Promise> { - const repoFilters: { draft?: boolean; noFutureAvailableDate?: boolean; userId?: EntityId } = { ...filters }; - const user = await this.authorizationService.getUserWithPermissions(creatorId); - if (!this.authorizationService.hasAllPermissions(user, [Permission.TASK_DASHBOARD_TEACHER_VIEW_V3])) { - repoFilters.userId = user.id; - } - - return this.taskRepo.findBySingleParent(creatorId, courseId, repoFilters, options); + return this.taskRepo.findBySingleParent(creatorId, courseId, filters, options); } async delete(task: Task): Promise { @@ -59,139 +36,7 @@ export class TaskService { await Promise.all(promises); } - async create(userId: EntityId, params: ITaskCreate): Promise { - const user = await this.authorizationService.getUserWithPermissions(userId); - const taskParams: ITaskProperties = { - ...params, - school: user.school, - creator: user, - }; - - this.taskDateValidation(taskParams.availableDate, taskParams.dueDate); - - if (!this.authorizationService.hasAllPermissions(user, [Permission.HOMEWORK_CREATE])) { - // TODO: Should be ForbiddenException - throw new UnauthorizedException(); - } - - if (params.courseId) { - const course = await this.courseRepo.findById(params.courseId); - this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.write([])); - taskParams.course = course; - - if (params.usersIds) { - const courseUsers = course.getStudentIds(); - const isAllUsersInCourse = params.usersIds.every((id) => courseUsers.includes(id)); - if (!isAllUsersInCourse) { - throw new ForbiddenException('Users do not belong to course'); - } - const users = await Promise.all(params.usersIds.map(async (id) => this.userRepo.findById(id))); - taskParams.users = users; - } - } - - if (params.lessonId) { - const lesson = await this.lessonRepo.findById(params.lessonId); - if (!taskParams.course || lesson.course.id !== taskParams.course.id) { - throw new ForbiddenException('Lesson does not belong to Course'); - } - this.authorizationService.checkPermission(user, lesson, AuthorizationContextBuilder.write([])); - taskParams.lesson = lesson; - } - - const task = new Task(taskParams); - - await this.taskRepo.save(task); - - const status = task.createTeacherStatusForUser(user); - const taskWithStatusVo = new TaskWithStatusVo(task, status); - - return taskWithStatusVo; - } - - async find(userId: EntityId, taskId: EntityId) { - const user = await this.authorizationService.getUserWithPermissions(userId); - const task = await this.taskRepo.findById(taskId); - - this.authorizationService.checkPermission(user, task, AuthorizationContextBuilder.read([Permission.HOMEWORK_VIEW])); - - const status = this.authorizationService.hasOneOfPermissions(user, [Permission.HOMEWORK_EDIT]) - ? task.createTeacherStatusForUser(user) - : task.createStudentStatusForUser(user); - - const result = new TaskWithStatusVo(task, status); - - return result; - } - async findById(taskId: EntityId): Promise { return this.taskRepo.findById(taskId); } - - async update(userId: EntityId, taskId: EntityId, params: ITaskUpdate, remove = false): Promise { - const user = await this.authorizationService.getUserWithPermissions(userId); - const task = await this.taskRepo.findById(taskId); - - this.authorizationService.checkPermission( - user, - task, - AuthorizationContextBuilder.write([Permission.HOMEWORK_EDIT]) - ); - - // eslint-disable-next-line no-restricted-syntax - for (const [key, value] of Object.entries(params)) { - if (value) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - task[key] = value; - } - } - - if (params.courseId) { - const course = await this.courseRepo.findById(params.courseId); - this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.write([])); - task.course = course; - - if (params.usersIds) { - const courseUsers = course.getStudentIds(); - const isAllUsersInCourse = params.usersIds.every((id) => courseUsers.includes(id)); - if (!isAllUsersInCourse) { - throw new ForbiddenException('Users do not belong to course'); - } - const users = await Promise.all(params.usersIds.map(async (id) => this.userRepo.findById(id))); - task.users.set(users); - } else if (remove) { - task.users.removeAll(); - } - } else if (remove) { - task.course = undefined; - task.lesson = undefined; - task.users.removeAll(); - } - - if (params.lessonId) { - const lesson = await this.lessonRepo.findById(params.lessonId); - if (!task.course || lesson.course.id !== task.course.id) { - throw new ForbiddenException('Lesson does not belong to Course'); - } - this.authorizationService.checkPermission(user, lesson, AuthorizationContextBuilder.write([])); - task.lesson = lesson; - } else if (remove) { - task.lesson = undefined; - } - - this.taskDateValidation(params.availableDate, params.dueDate); - - await this.taskRepo.save(task); - - const status = task.createTeacherStatusForUser(user); - const taskWithStatusVo = new TaskWithStatusVo(task, status); - - return taskWithStatusVo; - } - - private taskDateValidation(availableDate?: Date, dueDate?: Date) { - if (availableDate && dueDate && !(availableDate < dueDate)) { - throw new ValidationError('availableDate must be before dueDate'); - } - } } diff --git a/apps/server/src/modules/task/task.module.ts b/apps/server/src/modules/task/task.module.ts index dde7bb4b080..0d95bcc19b0 100644 --- a/apps/server/src/modules/task/task.module.ts +++ b/apps/server/src/modules/task/task.module.ts @@ -1,5 +1,5 @@ import { forwardRef, Module } from '@nestjs/common'; -import { CourseRepo, LessonRepo, SubmissionRepo, TaskRepo, UserRepo } from '@shared/repo'; +import { CourseRepo, LessonRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; import { AuthorizationModule } from '@src/modules/authorization'; import { CopyHelperModule } from '@src/modules/copy-helper'; import { FilesStorageClientModule } from '@src/modules/files-storage-client'; @@ -7,16 +7,7 @@ import { SubmissionService, TaskCopyService, TaskService } from './service'; @Module({ imports: [forwardRef(() => AuthorizationModule), FilesStorageClientModule, CopyHelperModule], - providers: [ - TaskService, - TaskCopyService, - SubmissionService, - TaskRepo, - LessonRepo, - CourseRepo, - SubmissionRepo, - UserRepo, - ], + providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, LessonRepo, CourseRepo, SubmissionRepo], exports: [TaskService, TaskCopyService, SubmissionService], }) export class TaskModule {} diff --git a/apps/server/src/modules/task/uc/task.uc.spec.ts b/apps/server/src/modules/task/uc/task.uc.spec.ts index 9c75f3b3bdb..e190db57d74 100644 --- a/apps/server/src/modules/task/uc/task.uc.spec.ts +++ b/apps/server/src/modules/task/uc/task.uc.spec.ts @@ -132,7 +132,6 @@ describe('TaskUC', () => { lessonIdsOfOpenCourses: [], lessonIdsOfFinishedCourses: [], }, - { userId: user.id }, { pagination: undefined, order: { dueDate: SortOrder.desc } }, ]; expect(taskRepo.findAllFinishedByParentIds).toHaveBeenCalledWith(...expectedParams); @@ -172,7 +171,6 @@ describe('TaskUC', () => { lessonIdsOfOpenCourses: [], lessonIdsOfFinishedCourses: [], }, - { userId: user.id }, { pagination: { skip }, order: { dueDate: SortOrder.desc } }, ]; expect(taskRepo.findAllFinishedByParentIds).toHaveBeenCalledWith(...expectedParams); @@ -191,7 +189,6 @@ describe('TaskUC', () => { lessonIdsOfOpenCourses: [], lessonIdsOfFinishedCourses: [], }, - { userId: user.id }, { pagination: { limit }, order: { dueDate: SortOrder.desc } }, ]; expect(taskRepo.findAllFinishedByParentIds).toHaveBeenCalledWith(...expectedParams); @@ -228,7 +225,6 @@ describe('TaskUC', () => { lessonIdsOfOpenCourses: [lesson.id], lessonIdsOfFinishedCourses: [], }, - { userId: user.id }, { pagination: undefined, order: { dueDate: SortOrder.desc } }, ]; expect(taskRepo.findAllFinishedByParentIds).toHaveBeenCalledWith(...expectedParams); @@ -265,7 +261,6 @@ describe('TaskUC', () => { lessonIdsOfOpenCourses: [], lessonIdsOfFinishedCourses: [], }, - { userId: user.id }, { pagination: undefined, order: { dueDate: SortOrder.desc } }, ]; expect(taskRepo.findAllFinishedByParentIds).toHaveBeenCalledWith(...expectedParams); @@ -376,7 +371,7 @@ describe('TaskUC', () => { }); }); - describe('when user is a student without task assignment ', () => { + describe('when user is a student', () => { const setup = () => { const permissions = [Permission.TASK_DASHBOARD_VIEW_V3]; const user = setupUser(permissions); diff --git a/apps/server/src/modules/task/uc/task.uc.ts b/apps/server/src/modules/task/uc/task.uc.ts index 344fb2de9d0..dae89d1e52f 100644 --- a/apps/server/src/modules/task/uc/task.uc.ts +++ b/apps/server/src/modules/task/uc/task.uc.ts @@ -4,9 +4,7 @@ import { Course, EntityId, IPagination, - ITaskCreate, ITaskStatus, - ITaskUpdate, Lesson, Permission, SortOrder, @@ -43,11 +41,6 @@ export class TaskUC { const lessonIdsOfOpenCourses = lessons.filter((l) => !l.course.isFinished()).map((l) => l.id); const lessonIdsOfFinishedCourses = lessons.filter((l) => l.course.isFinished()).map((l) => l.id); - let filters = {}; - if (!this.authorizationService.hasAllPermissions(user, [Permission.TASK_DASHBOARD_TEACHER_VIEW_V3])) { - filters = { userId: user.id }; - } - const [tasks, total] = await this.taskRepo.findAllFinishedByParentIds( { creatorId: userId, @@ -56,7 +49,6 @@ export class TaskUC { lessonIdsOfOpenCourses, lessonIdsOfFinishedCourses, }, - filters, { pagination, order: { dueDate: SortOrder.desc } } ); @@ -145,7 +137,7 @@ export class TaskUC { courseIds: openCourses.map((c) => c.id), lessonIds: lessons.map((l) => l.id), }, - { afterDueDateOrNone: dueDate, finished: notFinished, availableOn: new Date(), userId: user.id }, + { afterDueDateOrNone: dueDate, finished: notFinished, availableOn: new Date() }, { pagination, order: { dueDate: SortOrder.asc }, @@ -240,16 +232,4 @@ export class TaskUC { return true; } - - async create(userId: EntityId, params: ITaskCreate): Promise { - return this.taskService.create(userId, params); - } - - async update(userId: EntityId, taskId: EntityId, params: ITaskUpdate): Promise { - return this.taskService.update(userId, taskId, params, true); - } - - async find(userId: EntityId, taskId: EntityId) { - return this.taskService.find(userId, taskId); - } } diff --git a/apps/server/src/modules/teams/index.ts b/apps/server/src/modules/teams/index.ts new file mode 100644 index 00000000000..eb6818085df --- /dev/null +++ b/apps/server/src/modules/teams/index.ts @@ -0,0 +1,2 @@ +export * from './teams.module'; +export * from './service'; diff --git a/apps/server/src/modules/teams/service/index.ts b/apps/server/src/modules/teams/service/index.ts new file mode 100644 index 00000000000..24fa8967195 --- /dev/null +++ b/apps/server/src/modules/teams/service/index.ts @@ -0,0 +1 @@ +export * from './team.service'; diff --git a/apps/server/src/modules/teams/service/team.service.spec.ts b/apps/server/src/modules/teams/service/team.service.spec.ts new file mode 100644 index 00000000000..1406f90e0b9 --- /dev/null +++ b/apps/server/src/modules/teams/service/team.service.spec.ts @@ -0,0 +1,101 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TeamsRepo } from '@shared/repo'; +import { setupEntities, teamFactory, teamUserFactory } from '@shared/testing'; +import { TeamService } from './team.service'; + +describe('TeamService', () => { + let module: TestingModule; + let service: TeamService; + + let teamsRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TeamService, + { + provide: TeamsRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(TeamService); + teamsRepo = module.get(TeamsRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findUserDataFromTeams', () => { + describe('when finding by userId', () => { + const setup = () => { + const teamUser = teamUserFactory.buildWithId(); + const team1 = teamFactory.withTeamUser([teamUser]).build(); + const team2 = teamFactory.withTeamUser([teamUser]).build(); + + teamsRepo.findByUserId.mockResolvedValue([team1, team2]); + + return { + teamUser, + }; + }; + + it('should call teamsRepo.findByUserId', async () => { + const { teamUser } = setup(); + + await service.findUserDataFromTeams(teamUser.user.id); + + expect(teamsRepo.findByUserId).toBeCalledWith(teamUser.user.id); + }); + + it('should return array of two teams with user', async () => { + const { teamUser } = setup(); + + const result = await service.findUserDataFromTeams(teamUser.user.id); + + expect(result.length).toEqual(2); + }); + }); + }); + + describe('deleteUserDataFromTeams', () => { + describe('when deleting by userId', () => { + const setup = () => { + const teamUser = teamUserFactory.buildWithId(); + const team1 = teamFactory.withTeamUser([teamUser]).build(); + const team2 = teamFactory.withTeamUser([teamUser]).build(); + + teamsRepo.findByUserId.mockResolvedValue([team1, team2]); + + return { + teamUser, + }; + }; + + it('should call teamsRepo.findByUserId', async () => { + const { teamUser } = setup(); + + await service.deleteUserDataFromTeams(teamUser.user.id); + + expect(teamsRepo.findByUserId).toBeCalledWith(teamUser.user.id); + }); + + it('should update teams without deleted user', async () => { + const { teamUser } = setup(); + + const result = await service.deleteUserDataFromTeams(teamUser.user.id); + + expect(result).toEqual(2); + }); + }); + }); +}); diff --git a/apps/server/src/modules/teams/service/team.service.ts b/apps/server/src/modules/teams/service/team.service.ts new file mode 100644 index 00000000000..364d6b73e57 --- /dev/null +++ b/apps/server/src/modules/teams/service/team.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, TeamEntity } from '@shared/domain'; +import { TeamsRepo } from '@shared/repo'; + +@Injectable() +export class TeamService { + constructor(private readonly teamsRepo: TeamsRepo) {} + + public async findUserDataFromTeams(userId: EntityId): Promise { + const teams = await this.teamsRepo.findByUserId(userId); + + return teams; + } + + public async deleteUserDataFromTeams(userId: EntityId): Promise { + const teams = await this.teamsRepo.findByUserId(userId); + + teams.forEach((team) => { + team.userIds = team.userIds.filter((u) => u.userId.id !== userId); + }); + + await this.teamsRepo.save(teams); + + return teams.length; + } +} diff --git a/apps/server/src/modules/teams/teams-api.module.ts b/apps/server/src/modules/teams/teams-api.module.ts new file mode 100644 index 00000000000..5fc620fe76a --- /dev/null +++ b/apps/server/src/modules/teams/teams-api.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TeamsModule } from '@src/modules/teams/teams.module'; + +@Module({ + imports: [TeamsModule], + providers: [], + controllers: [], + exports: [], +}) +export class TeamsApiModule {} diff --git a/apps/server/src/modules/teams/teams.module.ts b/apps/server/src/modules/teams/teams.module.ts new file mode 100644 index 00000000000..cb83bf00d8e --- /dev/null +++ b/apps/server/src/modules/teams/teams.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TeamsRepo } from '@shared/repo'; +import { TeamService } from './service'; + +@Module({ + providers: [TeamService, TeamsRepo], + exports: [TeamService], +}) +export class TeamsModule {} diff --git a/apps/server/src/modules/tool/common/common-tool.module.ts b/apps/server/src/modules/tool/common/common-tool.module.ts index a3561b0e349..419385be67d 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -1,12 +1,27 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { AuthorizationModule } from '@src/modules/authorization'; +import { SchoolModule } from '@src/modules/school'; import { CommonToolService, CommonToolValidationService } from './service'; +import { ToolPermissionHelper } from './uc/tool-permission-helper'; @Module({ - imports: [LoggerModule], + imports: [LoggerModule, forwardRef(() => AuthorizationModule), SchoolModule], // TODO: make deletion of entities cascading, adjust ExternalToolService.deleteExternalTool and remove the repos from here - providers: [CommonToolService, CommonToolValidationService, SchoolExternalToolRepo, ContextExternalToolRepo], - exports: [CommonToolService, CommonToolValidationService, SchoolExternalToolRepo, ContextExternalToolRepo], + providers: [ + CommonToolService, + CommonToolValidationService, + ToolPermissionHelper, + SchoolExternalToolRepo, + ContextExternalToolRepo, + ], + exports: [ + CommonToolService, + CommonToolValidationService, + ToolPermissionHelper, + SchoolExternalToolRepo, + ContextExternalToolRepo, + ], }) export class CommonToolModule {} diff --git a/apps/server/src/shared/domain/domainobject/tool/custom-parameter-entry.do.ts b/apps/server/src/modules/tool/common/domain/custom-parameter-entry.do.ts similarity index 52% rename from apps/server/src/shared/domain/domainobject/tool/custom-parameter-entry.do.ts rename to apps/server/src/modules/tool/common/domain/custom-parameter-entry.do.ts index 4b36718918e..f80226ca7ee 100644 --- a/apps/server/src/shared/domain/domainobject/tool/custom-parameter-entry.do.ts +++ b/apps/server/src/modules/tool/common/domain/custom-parameter-entry.do.ts @@ -1,9 +1,9 @@ -export class CustomParameterEntryDO { +export class CustomParameterEntry { name: string; value?: string; - constructor(props: CustomParameterEntryDO) { + constructor(props: CustomParameterEntry) { this.name = props.name; this.value = props.value; } diff --git a/apps/server/src/shared/domain/domainobject/tool/custom-parameter.do.ts b/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts similarity index 77% rename from apps/server/src/shared/domain/domainobject/tool/custom-parameter.do.ts rename to apps/server/src/modules/tool/common/domain/custom-parameter.do.ts index a109fc8807b..fcad4784f43 100644 --- a/apps/server/src/shared/domain/domainobject/tool/custom-parameter.do.ts +++ b/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts @@ -1,6 +1,6 @@ -import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '@shared/domain'; +import { CustomParameterScope, CustomParameterLocation, CustomParameterType } from '../enum'; -export class CustomParameterDO { +export class CustomParameter { name: string; displayName: string; @@ -21,7 +21,7 @@ export class CustomParameterDO { isOptional: boolean; - constructor(props: CustomParameterDO) { + constructor(props: CustomParameter) { this.name = props.name; this.displayName = props.displayName; this.description = props.description; diff --git a/apps/server/src/modules/tool/common/domain/index.ts b/apps/server/src/modules/tool/common/domain/index.ts new file mode 100644 index 00000000000..ae59ec3f2a3 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/index.ts @@ -0,0 +1,3 @@ +export * from './custom-parameter.do'; +export * from './custom-parameter-entry.do'; +export * from '../enum/tool-configuration-status'; diff --git a/apps/server/src/shared/domain/entity/tools/custom-parameter-entry.ts b/apps/server/src/modules/tool/common/entity/custom-parameter-entry.entity.ts similarity index 67% rename from apps/server/src/shared/domain/entity/tools/custom-parameter-entry.ts rename to apps/server/src/modules/tool/common/entity/custom-parameter-entry.entity.ts index d3826ae7333..aa1296d42ce 100644 --- a/apps/server/src/shared/domain/entity/tools/custom-parameter-entry.ts +++ b/apps/server/src/modules/tool/common/entity/custom-parameter-entry.entity.ts @@ -1,14 +1,14 @@ import { Embeddable, Property } from '@mikro-orm/core'; @Embeddable() -export class CustomParameterEntry { +export class CustomParameterEntryEntity { @Property() name: string; @Property() value?: string; - constructor(props: CustomParameterEntry) { + constructor(props: CustomParameterEntryEntity) { this.name = props.name; this.value = props.value; } diff --git a/apps/server/src/modules/tool/common/entity/index.ts b/apps/server/src/modules/tool/common/entity/index.ts new file mode 100644 index 00000000000..c19d506474e --- /dev/null +++ b/apps/server/src/modules/tool/common/entity/index.ts @@ -0,0 +1 @@ +export * from './custom-parameter-entry.entity'; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-location.enum.ts b/apps/server/src/modules/tool/common/enum/custom-parameter-location.enum.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-location.enum.ts rename to apps/server/src/modules/tool/common/enum/custom-parameter-location.enum.ts diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-scope.enum.ts b/apps/server/src/modules/tool/common/enum/custom-parameter-scope.enum.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-scope.enum.ts rename to apps/server/src/modules/tool/common/enum/custom-parameter-scope.enum.ts diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter-type.enum.ts rename to apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts diff --git a/apps/server/src/modules/tool/common/enum/index.ts b/apps/server/src/modules/tool/common/enum/index.ts new file mode 100644 index 00000000000..24628ac19f5 --- /dev/null +++ b/apps/server/src/modules/tool/common/enum/index.ts @@ -0,0 +1,13 @@ +export * from './request-response/custom-parameter-location.enum'; +export * from './request-response/custom-parameter-scope-type.enum'; +export * from './request-response/custom-parameter-type.enum'; +export * from './lti-message-type.enum'; +export * from './lti-privacy-permission.enum'; +export * from './lti-role.enum'; +export * from './token-endpoint-auth-method.enum'; +export * from './tool-config-type.enum'; +export * from './tool-context-type.enum'; +export * from './custom-parameter-location.enum'; +export * from './custom-parameter-scope.enum'; +export * from './custom-parameter-type.enum'; +export * from './tool-configuration-status'; diff --git a/apps/server/src/modules/tool/common/interface/lti-message-type.enum.ts b/apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/lti-message-type.enum.ts rename to apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/lti-privacy-permission.enum.ts b/apps/server/src/modules/tool/common/enum/lti-privacy-permission.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/lti-privacy-permission.enum.ts rename to apps/server/src/modules/tool/common/enum/lti-privacy-permission.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/lti-role.enum.ts b/apps/server/src/modules/tool/common/enum/lti-role.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/lti-role.enum.ts rename to apps/server/src/modules/tool/common/enum/lti-role.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/custom-parameter-location.enum.ts b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-location.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/custom-parameter-location.enum.ts rename to apps/server/src/modules/tool/common/enum/request-response/custom-parameter-location.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/custom-parameter-scope-type.enum.ts b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-scope-type.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/custom-parameter-scope-type.enum.ts rename to apps/server/src/modules/tool/common/enum/request-response/custom-parameter-scope-type.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/custom-parameter-type.enum.ts rename to apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/token-endpoint-auth-method.enum.ts b/apps/server/src/modules/tool/common/enum/token-endpoint-auth-method.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/token-endpoint-auth-method.enum.ts rename to apps/server/src/modules/tool/common/enum/token-endpoint-auth-method.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/tool-config-type.enum.ts b/apps/server/src/modules/tool/common/enum/tool-config-type.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/tool-config-type.enum.ts rename to apps/server/src/modules/tool/common/enum/tool-config-type.enum.ts diff --git a/apps/server/src/shared/domain/domainobject/tool/tool-configuration-status.ts b/apps/server/src/modules/tool/common/enum/tool-configuration-status.ts similarity index 100% rename from apps/server/src/shared/domain/domainobject/tool/tool-configuration-status.ts rename to apps/server/src/modules/tool/common/enum/tool-configuration-status.ts diff --git a/apps/server/src/modules/tool/common/interface/tool-context-type.enum.ts b/apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts similarity index 100% rename from apps/server/src/modules/tool/common/interface/tool-context-type.enum.ts rename to apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts diff --git a/apps/server/src/modules/tool/common/interface/index.ts b/apps/server/src/modules/tool/common/interface/index.ts index 26ce871477a..b41fc78539c 100644 --- a/apps/server/src/modules/tool/common/interface/index.ts +++ b/apps/server/src/modules/tool/common/interface/index.ts @@ -1,10 +1,2 @@ -export * from './custom-parameter-location.enum'; -export * from './custom-parameter-scope-type.enum'; -export * from './custom-parameter-type.enum'; -export * from './lti-message-type.enum'; -export * from './lti-privacy-permission.enum'; -export * from './lti-role.enum'; -export * from './token-endpoint-auth-method.enum'; -export * from './tool-config-type.enum'; -export * from './tool-context-type.enum'; export * from './external-tool-search-query'; +export * from './tool-version.interface'; diff --git a/apps/server/src/shared/domain/domainobject/tool/types/tool-version.interface.ts b/apps/server/src/modules/tool/common/interface/tool-version.interface.ts similarity index 100% rename from apps/server/src/shared/domain/domainobject/tool/types/tool-version.interface.ts rename to apps/server/src/modules/tool/common/interface/tool-version.interface.ts diff --git a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts index 1f5af7c5806..00da0a8b36d 100644 --- a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts @@ -1,5 +1,5 @@ import { AuthorizableReferenceType } from '@src/modules/authorization/types'; -import { ToolContextType } from '../interface'; +import { ToolContextType } from '../enum'; const typeMapping: Record = { [ToolContextType.COURSE]: AuthorizableReferenceType.Course, 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 69507d5fb40..43a2aba1a39 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 @@ -1,18 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { - ContextExternalToolDO, - CustomParameterDO, - CustomParameterScope, - CustomParameterType, - ExternalToolDO, - SchoolExternalToolDO, -} from '@shared/domain'; -import { - contextExternalToolDOFactory, - customParameterDOFactory, - externalToolDOFactory, - schoolExternalToolDOFactory, + contextExternalToolFactory, + customParameterFactory, + externalToolFactory, + schoolExternalToolFactory, } from '@shared/testing'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { CustomParameter } from '../domain'; +import { CustomParameterScope, CustomParameterType } from '../enum'; import { CommonToolValidationService } from './common-tool-validation.service'; describe('CommonToolValidationService', () => { @@ -26,10 +23,96 @@ describe('CommonToolValidationService', () => { service = module.get(CommonToolValidationService); }); + describe('isValueValidForType', () => { + describe('when parameter type is string', () => { + it('should return true', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.STRING, 'test'); + + expect(result).toEqual(true); + }); + }); + + describe('when parameter type is boolean', () => { + describe('when value is true', () => { + it('should return true', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'true'); + + expect(result).toEqual(true); + }); + }); + + describe('when value is false', () => { + it('should return true', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'false'); + + expect(result).toEqual(true); + }); + }); + + describe('when value is not true or false', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'other'); + + expect(result).toEqual(false); + }); + }); + }); + + describe('when parameter type is number', () => { + describe('when value is a number', () => { + it('should return true', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.NUMBER, '1234'); + + expect(result).toEqual(true); + }); + }); + + describe('when value is not a number', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.NUMBER, 'NaN'); + + expect(result).toEqual(false); + }); + }); + }); + + describe('when defining a value for parameter of type auto_contextId', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_CONTEXTID, 'test'); + + expect(result).toEqual(false); + }); + }); + + describe('when defining a value for parameter of type auto_contextId', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_CONTEXTNAME, 'test'); + + expect(result).toEqual(false); + }); + }); + + describe('when defining a value for parameter of type auto_contextId', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_SCHOOLID, 'test'); + + expect(result).toEqual(false); + }); + }); + + describe('when defining a value for parameter of type auto_contextId', () => { + it('should return false', () => { + const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_SCHOOLNUMBER, 'test'); + + expect(result).toEqual(false); + }); + }); + }); + describe('checkForDuplicateParameters', () => { describe('when given parameters has a case sensitive duplicate', () => { const setup = () => { - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [ { name: 'nameDuplicate', value: 'value' }, { name: 'nameDuplicate', value: 'value' }, @@ -52,7 +135,7 @@ describe('CommonToolValidationService', () => { describe('when given parameters has case insensitive duplicate', () => { const setup = () => { - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [ { name: 'nameDuplicate', value: 'value' }, { name: 'nameduplicate', value: 'value' }, @@ -75,7 +158,7 @@ describe('CommonToolValidationService', () => { describe('when given parameters has no duplicates', () => { const setup = () => { - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [ { name: 'nameNoDuplicate1', value: 'value' }, { name: 'nameNoDuplicate2', value: 'value' }, @@ -99,21 +182,21 @@ describe('CommonToolValidationService', () => { describe('checkCustomParameterEntries', () => { const createTools = ( - externalToolMock?: Partial, - schoolExternalToolMock?: Partial, - contextExternalToolMock?: Partial + externalToolMock?: Partial, + schoolExternalToolMock?: Partial, + contextExternalToolMock?: Partial ) => { - const externalTool: ExternalToolDO = new ExternalToolDO({ - ...externalToolDOFactory.buildWithId(), + const externalTool: ExternalTool = new ExternalTool({ + ...externalToolFactory.buildWithId(), ...externalToolMock, }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ - ...schoolExternalToolDOFactory.buildWithId(), + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + ...schoolExternalToolFactory.buildWithId(), ...schoolExternalToolMock, }); const schoolExternalToolId = schoolExternalTool.id as string; - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.build({ - ...contextExternalToolDOFactory.buildWithId(), + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ...contextExternalToolFactory.buildWithId(), ...contextExternalToolMock, }); @@ -128,7 +211,7 @@ describe('CommonToolValidationService', () => { describe('when checking parameter is required', () => { describe('and given parameter is not optional and parameter value is empty', () => { const setup = () => { - const requiredParam: CustomParameterDO = customParameterDOFactory.build({ + const requiredParam: CustomParameter = customParameterFactory.build({ name: 'requiredParam', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, @@ -161,13 +244,13 @@ describe('CommonToolValidationService', () => { describe('when checking parameters of school external tool', () => { const setup = () => { - const requiredContextParam: CustomParameterDO = customParameterDOFactory.build({ + const requiredContextParam: CustomParameter = customParameterFactory.build({ name: 'missingContextParam', isOptional: false, scope: CustomParameterScope.CONTEXT, type: CustomParameterType.BOOLEAN, }); - const schoolParam: CustomParameterDO = customParameterDOFactory.build({ + const schoolParam: CustomParameter = customParameterFactory.build({ name: 'schoolParam', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.BOOLEAN, @@ -197,7 +280,7 @@ describe('CommonToolValidationService', () => { describe('when parameter is not school or context', () => { const setup = () => { - const notSchoolParam: CustomParameterDO = customParameterDOFactory.build({ + const notSchoolParam: CustomParameter = customParameterFactory.build({ name: 'notSchoolParam', scope: CustomParameterScope.GLOBAL, type: CustomParameterType.BOOLEAN, @@ -228,7 +311,7 @@ describe('CommonToolValidationService', () => { describe('when parameter scope is school', () => { describe('when required parameter is missing', () => { const setup = () => { - const missingParam: CustomParameterDO = customParameterDOFactory.build({ + const missingParam: CustomParameter = customParameterFactory.build({ name: 'isMissing', isOptional: false, scope: CustomParameterScope.SCHOOL, @@ -258,7 +341,7 @@ describe('CommonToolValidationService', () => { describe('when parameter is optional but is missing on params', () => { const setup = () => { - const param: CustomParameterDO = customParameterDOFactory.build({ + const param: CustomParameter = customParameterFactory.build({ name: 'notChecked', scope: CustomParameterScope.SCHOOL, isOptional: true, @@ -290,7 +373,7 @@ describe('CommonToolValidationService', () => { describe('when parameter scope is context', () => { describe('when required parameter is missing', () => { const setup = () => { - const missingParam: CustomParameterDO = customParameterDOFactory.build({ + const missingParam: CustomParameter = customParameterFactory.build({ name: 'isMissing', isOptional: false, scope: CustomParameterScope.CONTEXT, @@ -323,7 +406,7 @@ describe('CommonToolValidationService', () => { describe('when parameter is optional but is missing on params', () => { const setup = () => { - const param: CustomParameterDO = customParameterDOFactory.build({ + const param: CustomParameter = customParameterFactory.build({ name: 'notChecked', scope: CustomParameterScope.CONTEXT, isOptional: true, @@ -357,7 +440,7 @@ describe('CommonToolValidationService', () => { describe('when checking parameter type string', () => { const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ + const correctTypeParam: CustomParameter = customParameterFactory.build({ name: 'correctType', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, @@ -388,7 +471,7 @@ describe('CommonToolValidationService', () => { describe('when checking parameter type number', () => { describe('when type matches param value', () => { const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ + const correctTypeParam: CustomParameter = customParameterFactory.build({ name: 'correctType', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.NUMBER, @@ -418,7 +501,7 @@ describe('CommonToolValidationService', () => { describe('when type not matches param value', () => { const setup = () => { - const wrongTypeParam: CustomParameterDO = customParameterDOFactory.build({ + const wrongTypeParam: CustomParameter = customParameterFactory.build({ name: 'wrongType', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.NUMBER, @@ -450,7 +533,7 @@ describe('CommonToolValidationService', () => { describe('when checking parameter type boolean', () => { describe('when type matches param value', () => { const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ + const correctTypeParam: CustomParameter = customParameterFactory.build({ name: 'correctType', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.BOOLEAN, @@ -480,7 +563,7 @@ describe('CommonToolValidationService', () => { describe('when type not matches param value', () => { const setup = () => { - const wrongTypeParam: CustomParameterDO = customParameterDOFactory.build({ + const wrongTypeParam: CustomParameter = customParameterFactory.build({ name: 'wrongType', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.BOOLEAN, @@ -509,130 +592,10 @@ describe('CommonToolValidationService', () => { }); }); - describe('when checking parameter type auto_contextId', () => { - const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.AUTO_CONTEXTID, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'irgendeineId123' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when checking parameter type auto_contextName', () => { - const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.AUTO_CONTEXTNAME, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'irgendeineId123' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when checking parameter type auto_schoolId', () => { - const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.AUTO_SCHOOLID, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'irgendeineId123' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when checking parameter type auto_schoolnumber', () => { - const setup = () => { - const correctTypeParam: CustomParameterDO = customParameterDOFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.AUTO_SCHOOLNUMBER, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'irgendeineId123' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - describe('when validating regex', () => { describe('when no regex is given', () => { const setup = () => { - const undefinedRegex: CustomParameterDO = customParameterDOFactory.build({ + const undefinedRegex: CustomParameter = customParameterFactory.build({ name: 'undefinedRegex', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, @@ -664,7 +627,7 @@ describe('CommonToolValidationService', () => { describe('when regex is given and param value is valid', () => { const setup = () => { - const validRegex: CustomParameterDO = customParameterDOFactory.build({ + const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, @@ -696,7 +659,7 @@ describe('CommonToolValidationService', () => { describe('when regex is given and param value is invalid', () => { const setup = () => { - const validRegex: CustomParameterDO = customParameterDOFactory.build({ + const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, 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 1f463cbdf84..9d315a97f34 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 @@ -1,32 +1,36 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { - ContextExternalToolDO, - CustomParameterDO, - CustomParameterEntryDO, - CustomParameterScope, - CustomParameterType, - ExternalToolDO, - SchoolExternalToolDO, -} from '@shared/domain'; import { isNaN } from 'lodash'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { CustomParameter, CustomParameterEntry } from '../domain'; +import { CustomParameterScope, CustomParameterType } from '../enum'; -const typeCheckers: { [key in CustomParameterType]: (val: string) => boolean } = { - [CustomParameterType.STRING]: () => true, - [CustomParameterType.NUMBER]: (val: string) => !isNaN(Number(val)), - [CustomParameterType.BOOLEAN]: (val: string) => val === 'true' || val === 'false', - [CustomParameterType.AUTO_CONTEXTID]: () => true, - [CustomParameterType.AUTO_CONTEXTNAME]: () => true, - [CustomParameterType.AUTO_SCHOOLID]: () => true, - [CustomParameterType.AUTO_SCHOOLNUMBER]: () => true, -}; - -export type ValidatableTool = SchoolExternalToolDO | ContextExternalToolDO; +export type ValidatableTool = SchoolExternalTool | ContextExternalTool; @Injectable() export class CommonToolValidationService { + private static typeCheckers: { [key in CustomParameterType]: (val: string) => boolean } = { + [CustomParameterType.STRING]: () => true, + [CustomParameterType.NUMBER]: (val: string | undefined) => !isNaN(Number(val)), + [CustomParameterType.BOOLEAN]: (val: string | undefined) => val === 'true' || val === 'false', + [CustomParameterType.AUTO_CONTEXTID]: () => false, + [CustomParameterType.AUTO_CONTEXTNAME]: () => false, + [CustomParameterType.AUTO_SCHOOLID]: () => false, + [CustomParameterType.AUTO_SCHOOLNUMBER]: () => false, + }; + + public isValueValidForType(type: CustomParameterType, val: string): boolean { + const rule = CommonToolValidationService.typeCheckers[type]; + + const isValid: boolean = rule(val); + + return isValid; + } + public checkForDuplicateParameters(validatableTool: ValidatableTool): void { - const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntryDO) => + const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => name.toLowerCase() ); @@ -38,7 +42,7 @@ export class CommonToolValidationService { } } - public checkCustomParameterEntries(loadedExternalTool: ExternalToolDO, validatableTool: ValidatableTool) { + public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { if (loadedExternalTool.parameters) { for (const param of loadedExternalTool.parameters) { this.checkScopeAndValidateParameter(validatableTool, param); @@ -46,19 +50,19 @@ export class CommonToolValidationService { } } - private checkScopeAndValidateParameter(validatableTool: ValidatableTool, param: CustomParameterDO): void { - const foundEntry: CustomParameterEntryDO | undefined = validatableTool.parameters.find( - ({ name }: CustomParameterEntryDO): boolean => name.toLowerCase() === param.name.toLowerCase() + private checkScopeAndValidateParameter(validatableTool: ValidatableTool, param: CustomParameter): void { + const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( + ({ name }: CustomParameterEntry): boolean => name.toLowerCase() === param.name.toLowerCase() ); - if (param.scope === CustomParameterScope.SCHOOL && validatableTool instanceof SchoolExternalToolDO) { + if (param.scope === CustomParameterScope.SCHOOL && validatableTool instanceof SchoolExternalTool) { this.validateParameter(param, foundEntry); - } else if (param.scope === CustomParameterScope.CONTEXT && validatableTool instanceof ContextExternalToolDO) { + } else if (param.scope === CustomParameterScope.CONTEXT && validatableTool instanceof ContextExternalTool) { this.validateParameter(param, foundEntry); } } - private validateParameter(param: CustomParameterDO, foundEntry: CustomParameterEntryDO | undefined): void { + private validateParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { this.checkOptionalParameter(param, foundEntry); if (foundEntry) { this.checkParameterType(foundEntry, param); @@ -66,7 +70,7 @@ export class CommonToolValidationService { } } - private checkOptionalParameter(param: CustomParameterDO, foundEntry: CustomParameterEntryDO | undefined): void { + private checkOptionalParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { if (!foundEntry?.value && !param.isOptional) { throw new ValidationError( `tool_param_required: The parameter with name ${param.name} is required but not found in the tool.` @@ -74,15 +78,15 @@ export class CommonToolValidationService { } } - private checkParameterType(foundEntry: CustomParameterEntryDO, param: CustomParameterDO): void { - if (foundEntry.value !== undefined && !typeCheckers[param.type](foundEntry.value)) { + private checkParameterType(foundEntry: CustomParameterEntry, param: CustomParameter): void { + if (foundEntry.value !== undefined && !this.isValueValidForType(param.type, foundEntry.value)) { throw new ValidationError( `tool_param_type_mismatch: The value of parameter with name ${foundEntry.name} should be of type ${param.type}.` ); } } - private checkParameterRegex(foundEntry: CustomParameterEntryDO, param: CustomParameterDO): void { + private checkParameterRegex(foundEntry: CustomParameterEntry, param: CustomParameter): void { if (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/common/service/common-tool.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts index 20d4595e910..11677e6e916 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts @@ -1,7 +1,10 @@ -import { ContextExternalToolDO, ExternalToolDO, SchoolExternalToolDO, ToolConfigurationStatus } from '@shared/domain'; import { Test, TestingModule } from '@nestjs/testing'; -import { contextExternalToolDOFactory, externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; import { CommonToolService } from './common-tool.service'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ToolConfigurationStatus } from '../enum'; +import { ContextExternalTool } from '../../context-external-tool/domain'; describe('CommonToolService', () => { let module: TestingModule; @@ -22,9 +25,9 @@ describe('CommonToolService', () => { describe('determineToolConfigurationStatus', () => { describe('when all versions are equal', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 0 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 0 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 0 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 0 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 0 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 0 }); return { externalTool, @@ -48,9 +51,9 @@ describe('CommonToolService', () => { describe('when externalTool version is greater than schoolExternalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 0 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 0 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 0 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 0 }); return { externalTool, @@ -74,9 +77,9 @@ describe('CommonToolService', () => { describe('when schoolExternalTool version is greater than contextExternalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 1 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 0 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 0 }); return { externalTool, @@ -100,9 +103,9 @@ describe('CommonToolService', () => { describe('when externalTool version is greater than contextExternalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 1 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 0 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 0 }); return { externalTool, @@ -126,9 +129,9 @@ describe('CommonToolService', () => { describe('when contextExternalTool version is greater than schoolExternalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 1 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 2 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); return { externalTool, @@ -152,9 +155,9 @@ describe('CommonToolService', () => { describe('when contextExternalTool version is greater than externalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 1 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 2 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); return { externalTool, @@ -178,9 +181,9 @@ describe('CommonToolService', () => { describe('when schoolExternalTool version is greater than externalTool version', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId({ version: 1 }); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId({ toolVersion: 2 }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ toolVersion: 2 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 2 }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); return { externalTool, diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index 6c438dfecae..71f643c81ac 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -1,18 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { - ContextExternalToolDO, - ExternalToolDO, - SchoolExternalToolDO, - ToolConfigurationStatus, - ToolVersion, -} from '@shared/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ToolConfigurationStatus } from '../enum'; +import { ToolVersion } from '../interface'; @Injectable() export class CommonToolService { determineToolConfigurationStatus( - externalTool: ExternalToolDO, - schoolExternalTool: SchoolExternalToolDO, - contextExternalTool: ContextExternalToolDO + externalTool: ExternalTool, + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool ): ToolConfigurationStatus { if ( this.isLatest(schoolExternalTool, externalTool) && diff --git a/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts new file mode 100644 index 00000000000..6e47f6db97c --- /dev/null +++ b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts @@ -0,0 +1,48 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { EntityId, SchoolDO, User } from '@shared/domain'; +import { AuthorizableReferenceType, AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; +import { SchoolService } from '@src/modules/school'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextTypeMapper } from '../mapper'; + +@Injectable() +export class ToolPermissionHelper { + constructor( + @Inject(forwardRef(() => AuthorizationService)) private authorizationService: AuthorizationService, + private readonly schoolService: SchoolService + ) {} + + // TODO build interface to get contextDO by contextType + public async ensureContextPermissions( + userId: EntityId, + contextExternalTool: ContextExternalTool, + context: AuthorizationContext + ): Promise { + if (contextExternalTool.id) { + await this.authorizationService.checkPermissionByReferences( + userId, + AuthorizableReferenceType.ContextExternalToolEntity, + contextExternalTool.id, + context + ); + } + + await this.authorizationService.checkPermissionByReferences( + userId, + ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(contextExternalTool.contextRef.type), + contextExternalTool.contextRef.id, + context + ); + } + + public async ensureSchoolPermissions( + userId: EntityId, + schoolExternalTool: SchoolExternalTool, + context: AuthorizationContext + ): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: SchoolDO = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + this.authorizationService.checkPermission(user, school, context); + } +} diff --git a/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts b/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts new file mode 100644 index 00000000000..a42c1c5cbde --- /dev/null +++ b/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { contextExternalToolFactory, schoolDOFactory, schoolExternalToolFactory, setupEntities } from '@shared/testing'; +import { Permission, SchoolDO } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { SchoolService } from '@src/modules/school'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ToolPermissionHelper } from './tool-permission-helper'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; + +describe('ToolPermissionHelper', () => { + let module: TestingModule; + let helper: ToolPermissionHelper; + + let authorizationService: DeepMocked; + let schoolService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + ToolPermissionHelper, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, + ], + }).compile(); + + helper = module.get(ToolPermissionHelper); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(SchoolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('ensureContextPermissions', () => { + describe('when context external tool is given', () => { + const setup = () => { + const userId = 'userId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + return { + userId, + contextExternalTool, + context, + }; + }; + + it('should check permission for context external tool', async () => { + const { userId, contextExternalTool, context } = setup(); + + await helper.ensureContextPermissions(userId, contextExternalTool, context); + + expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + userId, + 'courses', + contextExternalTool.contextRef.id, + context + ); + }); + }); + }); + + describe('ensureSchoolPermissions', () => { + describe('when school external tool is given', () => { + const setup = () => { + const userId = 'userId'; + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + const school: SchoolDO = schoolDOFactory.build({ id: schoolExternalTool.schoolId }); + + schoolService.getSchoolById.mockResolvedValue(school); + + return { + userId, + schoolExternalTool, + school, + context, + }; + }; + + it('should check permission for school external tool', async () => { + const { userId, schoolExternalTool, context, school } = setup(); + + await helper.ensureSchoolPermissions(userId, schoolExternalTool, context); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(userId, school, context); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index 039421d4cf8..8991d84dcfc 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -1,64 +1,51 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Account, Course, Permission, School, User } from '@shared/domain'; import { - ContextExternalTool, - Course, - Permission, - Role, - RoleName, - School, - SchoolExternalTool, - User, -} from '@shared/domain'; -import { - contextExternalToolFactory, + accountFactory, + contextExternalToolEntityFactory, courseFactory, - mapUserToCurrentUser, + customParameterEntityFactory, + externalToolEntityFactory, roleFactory, - schoolExternalToolFactory, + schoolExternalToolEntityFactory, schoolFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { ICurrentUser, JwtAuthGuard } from '@src/modules/authentication'; import { ServerTestModule } from '@src/modules/server'; +import { ObjectId } from 'bson'; +import { CustomParameterScope, ToolContextType } from '../../../common/enum'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { ContextExternalToolPostParams, ContextExternalToolResponse, ContextExternalToolSearchListResponse, } from '../dto'; -import { ToolContextType } from '../../../common/interface'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; +import { ExternalToolEntity } from '../../../external-tool/entity'; +import { CustomParameterEntryResponse } from '../../../school-external-tool/controller/dto'; describe('ToolContextController (API)', () => { let app: INestApplication; let em: EntityManager; let orm: MikroORM; + let testApiClient: TestApiClient; - let currentUser: ICurrentUser | undefined; - - const basePath = '/tools/context'; + const basePath = '/tools/context-external-tools'; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleRef.createNestApplication(); await app.init(); em = app.get(EntityManager); orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath); }); afterAll(async () => { @@ -69,28 +56,25 @@ describe('ToolContextController (API)', () => { await orm.getSchemaGenerator().clearDatabase(); }); - describe('[POST] tools/context', () => { + describe('[POST] tools/context-external-tools', () => { describe('when creation of contextExternalTool is successfully', () => { const setup = async () => { - const teacherRole: Role = roleFactory.build({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const teacher: User = userFactory.buildWithId({ roles: [teacherRole], school }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); - const course: Course = courseFactory.buildWithId({ teachers: [teacher], school }); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const paramEntry = { name: 'name', value: 'value' }; - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const paramEntry: CustomParameterEntryResponse = { name: 'name', value: 'value' }; + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, schoolParameters: [paramEntry], toolVersion: 1, }); const postParams: ContextExternalToolPostParams = { - schoolToolId: schoolExternalTool.id, + schoolToolId: schoolExternalToolEntity.id, contextId: course.id, displayName: course.name, contextType: ToolContextType.COURSE, @@ -98,75 +82,49 @@ describe('ToolContextController (API)', () => { toolVersion: 1, }; - await em.persistAndFlush([teacherRole, course, school, teacher, schoolExternalTool]); + await em.persistAndFlush([teacherUser, teacherAccount, course, school, schoolExternalToolEntity]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + const expected: ContextExternalToolResponse = { + id: expect.any(String), + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: [paramEntry], + toolVersion: postParams.toolVersion, + }; + return { - schoolExternalTool, - course, - teacher, - paramEntry, + loggedInClient, postParams, + expected, }; }; - it('should create an contextExternalTool', async () => { - const { teacher, postParams } = await setup(); - currentUser = mapUserToCurrentUser(teacher); - - await request(app.getHttpServer()) - .post(basePath) - .send(postParams) - .expect(201) - .then((res: Response) => { - expect(res.body).toEqual( - expect.objectContaining({ - id: expect.any(String), - schoolToolId: postParams.schoolToolId, - contextId: postParams.contextId, - displayName: postParams.displayName, - contextType: postParams.contextType, - parameters: postParams.parameters, - toolVersion: postParams.toolVersion, - }) - ); - return res; - }); + it('should create a contextExternalTool', async () => { + const { postParams, loggedInClient, expected } = await setup(); - const createdContextExternalTool: ContextExternalTool | null = await em.findOne(ContextExternalTool, { - schoolTool: postParams.schoolToolId, - contextId: postParams.contextId, - }); + const response = await loggedInClient.post().send(postParams); - expect(createdContextExternalTool).toBeDefined(); + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual(expect.objectContaining(expected)); }); }); describe('when creation of contextExternalTool failed', () => { const setup = async () => { - const userWithMissingPermission: User = userFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const course: Course = courseFactory.buildWithId({ teachers: [userWithMissingPermission] }); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser] }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ schoolParameters: [], toolVersion: 1, }); - await em.persistAndFlush([course, userWithMissingPermission, schoolExternalTool]); - em.clear(); - - return { - schoolExternalTool, - course, - userWithMissingPermission, - }; - }; - - it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); - const randomTestId = new ObjectId().toString(); const postParams: ContextExternalToolPostParams = { schoolToolId: randomTestId, @@ -176,52 +134,67 @@ describe('ToolContextController (API)', () => { toolVersion: 1, }; - await request(app.getHttpServer()).post(basePath).send(postParams).expect(403); + await em.persistAndFlush([course, teacherUser, teacherAccount, schoolExternalToolEntity]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + postParams, + }; + }; + + it('when user is not authorized, it should return forbidden', async () => { + const { postParams, loggedInClient } = await setup(); + + const response = await loggedInClient.post().send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); }); }); - describe('[DELETE] tools/context/:contextExternalToolId', () => { + describe('[DELETE] tools/context-external-tools/:contextExternalToolId', () => { describe('when deletion of contextExternalTool is successfully', () => { const setup = async () => { - const teacherRole: Role = roleFactory.build({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const teacher: User = userFactory.buildWithId({ roles: [teacherRole], school }); - - const course: Course = courseFactory.buildWithId({ teachers: [teacher] }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser] }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ toolVersion: 1, school, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ contextId: course.id, - schoolTool: schoolExternalTool, + schoolTool: schoolExternalToolEntity, toolVersion: 1, }); - em.persist([teacherRole, course, teacher, schoolExternalTool, contextExternalTool]); + em.persist([course, teacherUser, teacherAccount, schoolExternalToolEntity, contextExternalToolEntity]); await em.flush(); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - contextExternalTool, - teacher, + loggedInClient, + contextExternalToolEntity, }; }; it('should delete an contextExternalTool', async () => { - const { teacher, contextExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(teacher); + const { loggedInClient, contextExternalToolEntity } = await setup(); - await request(app.getHttpServer()).delete(`${basePath}/${contextExternalTool.id}`).expect(200); + const result = await loggedInClient.delete(`${contextExternalToolEntity.id}`); - const deleted: ContextExternalTool | null = await em.findOne(ContextExternalTool, { - contextId: contextExternalTool.id, + expect(result.statusCode).toEqual(HttpStatus.NO_CONTENT); + + const deleted: ContextExternalToolEntity | null = await em.findOne(ContextExternalToolEntity, { + contextId: contextExternalToolEntity.id, }); expect(deleted).toBeNull(); @@ -230,94 +203,97 @@ describe('ToolContextController (API)', () => { describe('when deletion of contextExternalTool failed', () => { const setup = async () => { - const userWithMissingPermission: User = userFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const course: Course = courseFactory.buildWithId({ teachers: [userWithMissingPermission] }); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser] }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ toolVersion: 1, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, toolVersion: 1, }); - em.persist([course, userWithMissingPermission, schoolExternalTool, contextExternalTool]); + em.persist([course, teacherUser, teacherAccount, schoolExternalToolEntity, contextExternalToolEntity]); await em.flush(); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - contextExternalTool, - userWithMissingPermission, + contextExternalToolEntity, + loggedInClient, }; }; - it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission, contextExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); + it('when user is not authorized, it should return forbidden', async () => { + const { loggedInClient, contextExternalToolEntity } = await setup(); + + const result = await loggedInClient.delete(`${contextExternalToolEntity.id}`); - await request(app.getHttpServer()).delete(`${basePath}/${contextExternalTool.id}`).expect(403); + expect(result.statusCode).toEqual(HttpStatus.FORBIDDEN); }); }); }); - describe('[GET] tools/context/:contextType/:contextId', () => { + describe('[GET] tools/context-external-tools/:contextType/:contextId', () => { const setup = async () => { - const userRole: Role = roleFactory.build({ - name: RoleName.USER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); const otherSchool: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ roles: [userRole], school }); - const userFromOtherSchool: User = userFactory.buildWithId({ roles: [userRole], school: otherSchool }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + const otherTeacherUser: User = userFactory.buildWithId({ roles: [], school: otherSchool }); + const otherTeacherAccount: Account = accountFactory.buildWithId({ userId: otherTeacherUser.id }); const course: Course = courseFactory.buildWithId({ - students: [user], - teachers: [user, userFromOtherSchool], + students: [teacherUser], + teachers: [teacherUser, otherTeacherUser], school, }); - const schoolExternalTool1: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, toolVersion: 1, }); - const contextExternalTool1: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const contextExternalTool1: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ contextId: course.id, schoolTool: schoolExternalTool1, toolVersion: 1, }); - const schoolExternalTool2: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ toolVersion: 1, school, }); - const contextExternalTool2: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const contextExternalTool2: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ contextId: course.id, schoolTool: schoolExternalTool2, toolVersion: 1, }); - const schoolExternalToolFromOtherSchool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalToolFromOtherSchool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school: otherSchool, toolVersion: 1, }); - const contextExternalToolFromOtherSchool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - contextId: course.id, - schoolTool: schoolExternalToolFromOtherSchool, - toolVersion: 1, - }); + const contextExternalToolFromOtherSchool: ContextExternalToolEntity = + contextExternalToolEntityFactory.buildWithId({ + contextId: course.id, + schoolTool: schoolExternalToolFromOtherSchool, + toolVersion: 1, + }); em.persist([ - userRole, school, otherSchool, course, - user, - userFromOtherSchool, + teacherUser, + teacherAccount, + otherTeacherUser, + otherTeacherAccount, schoolExternalTool1, contextExternalTool1, schoolExternalTool2, @@ -328,22 +304,25 @@ describe('ToolContextController (API)', () => { await em.flush(); em.clear(); - currentUser = mapUserToCurrentUser(user); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + const otherLoggedInClient: TestApiClient = await testApiClient.login(otherTeacherAccount); return { contextExternalTool1, contextExternalTool2, - user, - userRole, + contextExternalToolFromOtherSchool, + loggedInClient, + otherLoggedInClient, }; }; describe('when user is authorized and has the required permissions', () => { it('should return context external tools he has permission for', async () => { - const { contextExternalTool1, contextExternalTool2 } = await setup(); + const { contextExternalTool1, contextExternalTool2, loggedInClient, contextExternalToolFromOtherSchool } = + await setup(); - const response: Response = await request(app.getHttpServer()).get( - `${basePath}/${contextExternalTool1.contextType}/${contextExternalTool1.contextId}` + const response = await loggedInClient.get( + `${contextExternalTool1.contextType}/${contextExternalTool1.contextId}` ); expect(response.status).toEqual(HttpStatus.OK); @@ -379,27 +358,44 @@ describe('ToolContextController (API)', () => { }, ], }); + expect(response.body).not.toEqual({ + data: [ + { + parameters: [ + { + name: contextExternalToolFromOtherSchool.parameters[0].name, + value: contextExternalToolFromOtherSchool.parameters[0].value, + }, + ], + id: contextExternalToolFromOtherSchool.id, + schoolToolId: contextExternalToolFromOtherSchool.schoolTool.id, + contextId: contextExternalToolFromOtherSchool.contextId, + contextType: ToolContextType.COURSE, + displayName: contextExternalToolFromOtherSchool.displayName, + toolVersion: contextExternalToolFromOtherSchool.toolVersion, + }, + ], + }); }); describe('when user is not authorized', () => { it('should return unauthorized', async () => { const { contextExternalTool1 } = await setup(); - currentUser = undefined; - await request(app.getHttpServer()) - .get(`${basePath}/${contextExternalTool1.contextType}/${contextExternalTool1.contextId}`) - .expect(HttpStatus.UNAUTHORIZED); + const response = await testApiClient.get( + `${contextExternalTool1.contextType}/${contextExternalTool1.contextId}` + ); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); }); }); describe('when user has not the required permission', () => { it('should return response with no tools', async () => { - const { contextExternalTool1, userRole } = await setup(); - userRole.permissions = []; - await em.persistAndFlush(userRole); + const { contextExternalTool1, otherLoggedInClient } = await setup(); - const response: Response = await request(app.getHttpServer()).get( - `${basePath}/${contextExternalTool1.contextType}/${contextExternalTool1.contextId}` + const response = await otherLoggedInClient.get( + `${contextExternalTool1.contextType}/${contextExternalTool1.contextId}` ); expect(response.status).toEqual(HttpStatus.OK); @@ -410,4 +406,428 @@ describe('ToolContextController (API)', () => { }); }); }); + + describe('[GET] tools/context-external-tools/:contextExternalToolId', () => { + describe('when the tool exists', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ + teachers: [teacherUser], + school, + }); + + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + toolVersion: 1, + }); + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + contextId: course.id, + schoolTool: schoolExternalTool, + toolVersion: 1, + contextType: ContextExternalToolType.COURSE, + }); + + await em.persistAndFlush([ + school, + course, + externalTool, + schoolExternalTool, + contextExternalTool, + teacherAccount, + teacherUser, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + contextExternalTool, + schoolExternalTool, + }; + }; + + it('should return tool in specific context', async () => { + const { contextExternalTool, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${contextExternalTool.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + schoolToolId: contextExternalTool.schoolTool.id, + contextId: contextExternalTool.contextId, + contextType: ToolContextType.COURSE, + id: contextExternalTool.id, + displayName: contextExternalTool.displayName, + parameters: contextExternalTool.parameters, + toolVersion: contextExternalTool.toolVersion, + }); + }); + }); + + describe('when the tool does not exist', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ + teachers: [teacherUser], + school, + }); + + await em.persistAndFlush([school, course, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return not found', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${new ObjectId().toHexString()}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when user is not authorized', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const course: Course = courseFactory.buildWithId({ + school, + }); + + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + toolVersion: 1, + }); + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + contextId: course.id, + schoolTool: schoolExternalTool, + toolVersion: 1, + contextType: ContextExternalToolType.COURSE, + }); + + await em.persistAndFlush([school, course, externalTool, schoolExternalTool, contextExternalTool]); + em.clear(); + + return { + contextExternalTool, + schoolExternalTool, + }; + }; + + it('should return unauthorized', async () => { + const { contextExternalTool } = await setup(); + + const response = await testApiClient.get(`${contextExternalTool.contextType}/${contextExternalTool.contextId}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); + + const course: Course = courseFactory.buildWithId({ + teachers: [studentUser], + school, + }); + + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + toolVersion: 1, + }); + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + contextId: course.id, + schoolTool: schoolExternalTool, + toolVersion: 1, + contextType: ContextExternalToolType.COURSE, + }); + + await em.persistAndFlush([ + school, + course, + externalTool, + schoolExternalTool, + contextExternalTool, + studentAccount, + studentUser, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { + contextExternalTool, + schoolExternalTool, + loggedInClient, + }; + }; + + it('should return forbidden', async () => { + const { contextExternalTool, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${contextExternalTool.id}`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + }); + + describe('[PUT] tools/context-external-tools/:contextExternalToolId', () => { + describe('when update of contextExternalTool is successfully', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + + const contextParameter = customParameterEntityFactory.build({ + scope: CustomParameterScope.CONTEXT, + regex: 'testValue123', + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [contextParameter], + version: 2, + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + toolVersion: 2, + }); + + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + displayName: 'CoolTool123', + parameters: [], + toolVersion: 1, + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + contextType: ToolContextType.COURSE, + displayName: 'CoolTool123', + parameters: [ + { + name: contextParameter.name, + value: 'testValue123', + }, + ], + toolVersion: 2, + }; + + await em.persistAndFlush([ + course, + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + contextExternalToolEntity, + course, + teacherUser, + postParams, + loggedInClient, + }; + }; + + it('should update an contextExternalTool', async () => { + const { contextExternalToolEntity, postParams, loggedInClient } = await setup(); + + const response = await loggedInClient.put(`${contextExternalToolEntity.id}`).send(postParams); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + id: contextExternalToolEntity.id, + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: postParams.parameters, + toolVersion: postParams.toolVersion, + }); + }); + }); + + describe('when the user is not authorized', () => { + const setup = async () => { + const roleWithoutPermission = roleFactory.buildWithId(); + + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher(); + + teacherUser.roles.set([roleWithoutPermission]); + + const school: School = schoolFactory.buildWithId(); + + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + + const contextParameter = customParameterEntityFactory.build({ + scope: CustomParameterScope.CONTEXT, + regex: 'testValue123', + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [contextParameter], + version: 2, + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + toolVersion: 2, + }); + + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + displayName: 'CoolTool123', + parameters: [], + toolVersion: 1, + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + contextType: ToolContextType.COURSE, + displayName: 'CoolTool123', + parameters: [ + { + name: contextParameter.name, + value: 'testValue123', + }, + ], + toolVersion: 2, + }; + + await em.persistAndFlush([ + course, + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, postParams, contextExternalToolEntity }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, contextExternalToolEntity, postParams } = await setup(); + + const response = await loggedInClient.put(`${contextExternalToolEntity.id}`).send(postParams); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the user is not authenticated', () => { + const setup = async () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const school: School = schoolFactory.buildWithId(); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + + const contextParameter = customParameterEntityFactory.build({ + scope: CustomParameterScope.CONTEXT, + regex: 'testValue123', + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [contextParameter], + version: 2, + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + toolVersion: 2, + }); + + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + displayName: 'CoolTool123', + parameters: [], + toolVersion: 1, + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + contextType: ToolContextType.COURSE, + displayName: 'CoolTool123', + parameters: [ + { + name: contextParameter.name, + value: 'testValue123', + }, + ], + toolVersion: 2, + }; + + await em.persistAndFlush([ + course, + school, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + return { contextExternalToolEntity, postParams }; + }; + + it('should return unauthorized', async () => { + const { contextExternalToolEntity, postParams } = await setup(); + + const response = await testApiClient.put(`${contextExternalToolEntity.id}`).send(postParams); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts index 7a77bcd77ea..7d20deef026 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsMongoId } from 'class-validator'; -import { ToolContextType } from '../../../common/interface'; +import { ToolContextType } from '../../../common/enum'; export class ContextExternalToolContextParams { @ApiProperty({ nullable: false, required: true, example: '0000dcfbfb5c7a3f00bf21ab' }) diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-post.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-post.params.ts index b51a16907c6..b5323717db1 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-post.params.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-post.params.ts @@ -1,8 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsMongoId, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { ToolContextType } from '../../../common/interface'; import { CustomParameterEntryParam } from '../../../school-external-tool/controller/dto'; +import { ToolContextType } from '../../../common/enum'; export class ContextExternalToolPostParams { @ApiProperty() diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts index 85436025a43..62f68fb7b4c 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ToolContextType } from '../../../common/interface'; import { CustomParameterEntryResponse } from '../../../school-external-tool/controller/dto'; +import { ToolContextType } from '../../../common/enum'; export class ContextExternalToolResponse { @ApiProperty() diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts index dbd68db6b65..491e7d9f2d6 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts @@ -1,7 +1,8 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { - ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiResponse, @@ -9,13 +10,14 @@ import { ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ContextExternalToolDO } from '@shared/domain'; +import { LegacyLogger } from '@src/core/logger'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; -import { LegacyLogger } from '@src/core/logger'; +import { ContextExternalTool } from '../domain'; import { ContextExternalToolRequestMapper, ContextExternalToolResponseMapper } from '../mapper'; +import { ContextExternalToolUc } from '../uc'; +import { ContextExternalToolDto } from '../uc/dto/context-external-tool.types'; import { ContextExternalToolContextParams, ContextExternalToolIdParams, @@ -23,12 +25,10 @@ import { ContextExternalToolResponse, ContextExternalToolSearchListResponse, } from './dto'; -import { ContextExternalToolUc } from '../uc'; -import { ContextExternalTool } from '../uc/dto/context-external-tool.types'; @ApiTags('Tool') @Authenticate('jwt') -@Controller('tools/context') +@Controller('tools/context-external-tools') export class ToolContextController { constructor(private readonly contextExternalToolUc: ContextExternalToolUc, private readonly logger: LegacyLogger) {} @@ -46,10 +46,10 @@ export class ToolContextController { @CurrentUser() currentUser: ICurrentUser, @Body() body: ContextExternalToolPostParams ): Promise { - const contextExternalTool: ContextExternalTool = + const contextExternalTool: ContextExternalToolDto = ContextExternalToolRequestMapper.mapContextExternalToolRequest(body); - const createdTool: ContextExternalToolDO = await this.contextExternalToolUc.createContextExternalTool( + const createdTool: ContextExternalTool = await this.contextExternalToolUc.createContextExternalTool( currentUser.userId, contextExternalTool ); @@ -65,6 +65,7 @@ export class ToolContextController { @ApiForbiddenResponse() @ApiUnauthorizedResponse() @ApiOperation({ summary: 'Deletes a ContextExternalTool' }) + @HttpCode(HttpStatus.NO_CONTENT) async deleteContextExternalTool( @CurrentUser() currentUser: ICurrentUser, @Param() params: ContextExternalToolIdParams @@ -77,7 +78,6 @@ export class ToolContextController { } @Get(':contextType/:contextId') - @ApiBearerAuth() @ApiForbiddenResponse() @ApiUnauthorizedResponse() @ApiOkResponse({ @@ -108,4 +108,57 @@ export class ToolContextController { const response: ContextExternalToolSearchListResponse = new ContextExternalToolSearchListResponse(mappedTools); return response; } + + @Get(':contextExternalToolId') + @ApiForbiddenResponse() + @ApiUnauthorizedResponse() + @ApiNotFoundResponse() + @ApiOkResponse({ + description: 'Returns a ContextExternalTool for the given id', + type: ContextExternalToolResponse, + }) + @ApiOperation({ summary: 'Searches a ContextExternalTool for the given id' }) + async getContextExternalTool( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ContextExternalToolIdParams + ): Promise { + const contextExternalTool: ContextExternalTool = await this.contextExternalToolUc.getContextExternalTool( + currentUser.userId, + params.contextExternalToolId + ); + + const response: ContextExternalToolResponse = + ContextExternalToolResponseMapper.mapContextExternalToolResponse(contextExternalTool); + + return response; + } + + @Put(':contextExternalToolId') + @ApiOkResponse({ + description: 'The ContextExternalTool has been successfully updated.', + type: ContextExternalToolResponse, + }) + @ApiForbiddenResponse() + @ApiUnauthorizedResponse() + @ApiUnprocessableEntityResponse() + @ApiOperation({ summary: 'Updates a ContextExternalTool' }) + async updateContextExternalTool( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ContextExternalToolIdParams, + @Body() body: ContextExternalToolPostParams + ): Promise { + const contextExternalTool: ContextExternalToolDto = + ContextExternalToolRequestMapper.mapContextExternalToolRequest(body); + + const updatedTool: ContextExternalTool = await this.contextExternalToolUc.updateContextExternalTool( + currentUser.userId, + params.contextExternalToolId, + contextExternalTool + ); + + const response: ContextExternalToolResponse = + ContextExternalToolResponseMapper.mapContextExternalToolResponse(updatedTool); + + return response; + } } diff --git a/apps/server/src/shared/domain/domainobject/tool/context-external-tool.do.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts similarity index 62% rename from apps/server/src/shared/domain/domainobject/tool/context-external-tool.do.ts rename to apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts index 2433f1d4706..3f796b97001 100644 --- a/apps/server/src/shared/domain/domainobject/tool/context-external-tool.do.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts @@ -1,8 +1,8 @@ -import { BaseDO } from '../base.do'; -import { CustomParameterEntryDO } from './custom-parameter-entry.do'; -import { SchoolExternalToolRefDO } from './school-external-tool-ref.do'; +import { BaseDO } from '@shared/domain/domainobject/base.do'; +import { CustomParameterEntry } from '../../common/domain'; +import { ToolVersion } from '../../common/interface'; +import { SchoolExternalToolRefDO } from '../../school-external-tool/domain'; import { ContextRef } from './context-ref'; -import { ToolVersion } from './types'; export interface ContextExternalToolProps { id?: string; @@ -13,19 +13,19 @@ export interface ContextExternalToolProps { displayName?: string; - parameters: CustomParameterEntryDO[]; + parameters: CustomParameterEntry[]; toolVersion: number; } -export class ContextExternalToolDO extends BaseDO implements ToolVersion { +export class ContextExternalTool extends BaseDO implements ToolVersion { schoolToolRef: SchoolExternalToolRefDO; contextRef: ContextRef; displayName?: string; - parameters: CustomParameterEntryDO[]; + parameters: CustomParameterEntry[]; toolVersion: number; diff --git a/apps/server/src/shared/domain/domainobject/tool/context-ref.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-ref.ts similarity index 65% rename from apps/server/src/shared/domain/domainobject/tool/context-ref.ts rename to apps/server/src/modules/tool/context-external-tool/domain/context-ref.ts index ae75f442b4d..6ed89f55a32 100644 --- a/apps/server/src/shared/domain/domainobject/tool/context-ref.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-ref.ts @@ -1,4 +1,4 @@ -import { ToolContextType } from 'apps/server/src/modules/tool/common/interface'; +import { ToolContextType } from '../../common/enum'; export class ContextRef { id: string; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts new file mode 100644 index 00000000000..a012e1d4002 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -0,0 +1,2 @@ +export * from './context-external-tool.do'; +export * from './context-ref'; diff --git a/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool-type.enum.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool-type.enum.ts rename to apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts new file mode 100644 index 00000000000..29f2b3080d1 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts @@ -0,0 +1,72 @@ +import { + contextExternalToolEntityFactory, + externalToolEntityFactory, + schoolExternalToolEntityFactory, + schoolFactory, + setupEntities, +} from '@shared/testing'; +import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; + +import { + BasicToolConfigEntity, + CustomParameterEntity, + ExternalToolEntity, + ExternalToolConfigEntity, +} from '../../external-tool/entity'; +import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; +import { ContextExternalToolEntity } from './context-external-tool.entity'; + +describe('ExternalToolEntity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new ContextExternalToolEntity(); + expect(test).toThrow(); + }); + + it('should create an external course Tool by passing required properties', () => { + const externalToolConfigEntity: ExternalToolConfigEntity = new BasicToolConfigEntity({ + type: ToolConfigType.BASIC, + baseUrl: 'mockBaseUrl', + }); + const customParameter: CustomParameterEntity = new CustomParameterEntity({ + name: 'parameterName', + displayName: 'User Friendly Name', + default: 'mock', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + name: 'toolName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + config: externalToolConfigEntity, + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + }); + const schoolTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school: schoolFactory.buildWithId(), + schoolParameters: [], + toolVersion: 1, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool, + parameters: [], + toolVersion: 1, + }); + + expect(contextExternalToolEntity instanceof ContextExternalToolEntity).toEqual(true); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts similarity index 62% rename from apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.ts rename to apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts index 817ad6e0f72..6fdbad2b3af 100644 --- a/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts @@ -1,11 +1,11 @@ import { Embedded, Entity, ManyToOne, Property } from '@mikro-orm/core'; -import { BaseEntityWithTimestamps } from '../../base.entity'; -import { CustomParameterEntry } from '../custom-parameter-entry'; -import { SchoolExternalTool } from '../school-external-tool/school-external-tool.entity'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { CustomParameterEntryEntity } from '../../common/entity'; +import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; import { ContextExternalToolType } from './context-external-tool-type.enum'; export interface IContextExternalToolProperties { - schoolTool: SchoolExternalTool; + schoolTool: SchoolExternalToolEntity; contextId: string; @@ -13,15 +13,15 @@ export interface IContextExternalToolProperties { displayName?: string; - parameters?: CustomParameterEntry[]; + parameters?: CustomParameterEntryEntity[]; toolVersion: number; } @Entity({ tableName: 'context_external_tools' }) -export class ContextExternalTool extends BaseEntityWithTimestamps { +export class ContextExternalToolEntity extends BaseEntityWithTimestamps { @ManyToOne() - schoolTool: SchoolExternalTool; + schoolTool: SchoolExternalToolEntity; @Property() contextId: string; @@ -32,8 +32,8 @@ export class ContextExternalTool extends BaseEntityWithTimestamps { @Property({ nullable: true }) displayName?: string; - @Embedded(() => CustomParameterEntry, { array: true }) - parameters: CustomParameterEntry[]; + @Embedded(() => CustomParameterEntryEntity, { array: true }) + parameters: CustomParameterEntryEntity[]; @Property() toolVersion: number; diff --git a/apps/server/src/shared/domain/entity/tools/course-external-tool/index.ts b/apps/server/src/modules/tool/context-external-tool/entity/index.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/course-external-tool/index.ts rename to apps/server/src/modules/tool/context-external-tool/entity/index.ts diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts index 6f05ccddbad..45f912bf52c 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts @@ -1,10 +1,10 @@ -import { CustomParameterEntryDO } from '@shared/domain/domainobject/tool'; import { ContextExternalToolPostParams } from '../controller/dto'; import { CustomParameterEntryParam } from '../../school-external-tool/controller/dto'; -import { ContextExternalTool } from '../uc/dto/context-external-tool.types'; +import { ContextExternalToolDto } from '../uc/dto/context-external-tool.types'; +import { CustomParameterEntry } from '../../common/domain'; export class ContextExternalToolRequestMapper { - static mapContextExternalToolRequest(request: ContextExternalToolPostParams): ContextExternalTool { + static mapContextExternalToolRequest(request: ContextExternalToolPostParams): ContextExternalToolDto { return { id: '', schoolToolRef: { @@ -22,7 +22,7 @@ export class ContextExternalToolRequestMapper { private static mapRequestToCustomParameterEntryDO( customParameterParams: CustomParameterEntryParam[] - ): CustomParameterEntryDO[] { + ): CustomParameterEntry[] { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts index d481538badf..601c960299d 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts @@ -1,6 +1,6 @@ -import { ContextExternalToolResponse } from '../controller/dto'; import { CustomParameterEntryParam, CustomParameterEntryResponse } from '../../school-external-tool/controller/dto'; -import { ContextExternalTool } from '../uc/dto/context-external-tool.types'; +import { ContextExternalToolResponse } from '../controller/dto'; +import { ContextExternalTool } from '../domain'; export class ContextExternalToolResponseMapper { static mapContextExternalToolResponse(contextExternalTool: ContextExternalTool): ContextExternalToolResponse { diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.spec.ts index 79fca74188a..96828243654 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.spec.ts @@ -1,9 +1,10 @@ -import { contextExternalToolDOFactory, schoolDOFactory, schoolExternalToolDOFactory } from '@shared/testing'; -import { ContextExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; +import { contextExternalToolFactory, schoolDOFactory, schoolExternalToolFactory } from '@shared/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ContextExternalToolRepo } from '@shared/repo'; import { ContextExternalToolAuthorizableService } from './context-external-tool-authorizable.service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextExternalTool } from '../domain'; describe('ContextExternalToolAuthorizableService', () => { let module: TestingModule; @@ -38,10 +39,10 @@ describe('ContextExternalToolAuthorizableService', () => { describe('when id is given', () => { const setup = () => { const schoolId: string = schoolDOFactory.buildWithId().id as string; - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ schoolId, }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory + const contextExternalTool: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) .build(); @@ -56,7 +57,7 @@ describe('ContextExternalToolAuthorizableService', () => { it('should return a contextExternalTool', async () => { const { contextExternalTool, contextExternalToolId } = setup(); - const result: ContextExternalToolDO = await service.findById(contextExternalToolId); + const result: ContextExternalTool = await service.findById(contextExternalToolId); expect(result).toEqual(contextExternalTool); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.ts index 7ed10d8565f..9f0606c6e14 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service.ts @@ -1,14 +1,15 @@ import { AuthorizationLoaderService } from '@src/modules/authorization'; -import { ContextExternalToolDO, EntityId } from '@shared/domain'; +import { EntityId } from '@shared/domain'; import { ContextExternalToolRepo } from '@shared/repo'; import { Injectable } from '@nestjs/common'; +import { ContextExternalTool } from '../domain'; @Injectable() export class ContextExternalToolAuthorizableService implements AuthorizationLoaderService { constructor(private readonly contextExternalToolRepo: ContextExternalToolRepo) {} - async findById(id: EntityId): Promise { - const contextExternalTool: ContextExternalToolDO = await this.contextExternalToolRepo.findById(id); + async findById(id: EntityId): Promise { + const contextExternalTool: ContextExternalTool = await this.contextExternalToolRepo.findById(id); return contextExternalTool; } diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts index 2cff72d0fb6..c419154c020 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts @@ -1,11 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO, ExternalToolDO } from '@shared/domain'; -import { contextExternalToolDOFactory, externalToolDOFactory } from '@shared/testing'; +import { contextExternalToolFactory, externalToolFactory } from '@shared/testing'; +import { ValidationError } from '@mikro-orm/core'; import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ContextExternalTool } from '../domain'; import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; import { ContextExternalToolService } from './context-external-tool.service'; @@ -57,13 +59,17 @@ describe('ContextExternalToolValidationService', () => { }); describe('validate', () => { - describe('when check duplication of contextExternalTool is successfully ', () => { + describe('when no tool with the name exists in the context', () => { const setup = () => { - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); externalToolService.findExternalToolById.mockResolvedValue(externalTool); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); - contextExternalToolService.findContextExternalTools.mockResolvedValue([]); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Tool 1', + }); + contextExternalToolService.findContextExternalTools.mockResolvedValue([ + contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), + ]); return { externalTool, @@ -108,26 +114,59 @@ describe('ContextExternalToolValidationService', () => { const func = () => service.validate(contextExternalTool); - await expect(func()).resolves.not.toThrowError(new UnprocessableEntityException()); + await expect(func()).resolves.not.toThrowError(UnprocessableEntityException); }); }); - describe('when check duplication of contextExternalTool failed ', () => { - const setup = () => { - const contextExternalTool = contextExternalToolDOFactory.buildWithId(); - contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool]); + describe('when a tool with the same name already exists in that context', () => { + describe('when the displayName is undefined', () => { + const setup = () => { + const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: undefined }); + const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: undefined }); - return { - contextExternalTool, + contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); + + return { + contextExternalTool1, + }; }; - }; - it('should throw UnprocessableEntityException', async () => { - const { contextExternalTool } = setup(); + it('should throw ValidationError', async () => { + const { contextExternalTool1 } = setup(); - const func = () => service.validate(contextExternalTool); + const func = () => service.validate(contextExternalTool1); + + await expect(func()).rejects.toThrowError( + new ValidationError( + 'tool_with_name_exists: A tool with the same name is already assigned to this course. Tool names must be unique within a course.' + ) + ); + }); + }); + + describe('when the displayName is the same', () => { + const setup = () => { + const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); + const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); - await expect(func()).rejects.toThrowError(new UnprocessableEntityException('Tool is already assigned.')); + contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); + + return { + contextExternalTool1, + }; + }; + + it('should throw ValidationError', async () => { + const { contextExternalTool1 } = setup(); + + const func = () => service.validate(contextExternalTool1); + + await expect(func()).rejects.toThrowError( + new ValidationError( + 'tool_with_name_exists: A tool with the same name is already assigned to this course. Tool names must be unique within a course.' + ) + ); + }); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index 79918e8064e..af6d36840f7 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -1,9 +1,12 @@ -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { ContextExternalToolDO, ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; +import { Injectable } from '@nestjs/common'; +import { ValidationError } from '@shared/common'; import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalTool } from '../uc/dto/context-external-tool.types'; +import { ContextExternalTool } from '../domain'; +import { ContextExternalToolDto } from '../uc/dto/context-external-tool.types'; import { ContextExternalToolService } from './context-external-tool.service'; @Injectable() @@ -15,29 +18,38 @@ export class ContextExternalToolValidationService { private readonly commonToolValidationService: CommonToolValidationService ) {} - async validate(toValidate: ContextExternalTool): Promise { - const contextExternalTool: ContextExternalToolDO = new ContextExternalToolDO(toValidate); + async validate(toValidate: ContextExternalToolDto): Promise { + const contextExternalTool: ContextExternalTool = new ContextExternalTool(toValidate); await this.checkDuplicateInContext(contextExternalTool); - const loadedSchoolExternalTool: SchoolExternalToolDO = - await this.schoolExternalToolService.getSchoolExternalToolById(contextExternalTool.schoolToolRef.schoolToolId); + const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + contextExternalTool.schoolToolRef.schoolToolId + ); - const loadedExternalTool: ExternalToolDO = await this.externalToolService.findExternalToolById( + const loadedExternalTool: ExternalTool = await this.externalToolService.findExternalToolById( loadedSchoolExternalTool.toolId ); this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, contextExternalTool); } - private async checkDuplicateInContext(contextExternalTool: ContextExternalToolDO) { - const duplicate: ContextExternalToolDO[] = await this.contextExternalToolService.findContextExternalTools({ + private async checkDuplicateInContext(contextExternalTool: ContextExternalTool) { + let duplicate: ContextExternalTool[] = await this.contextExternalToolService.findContextExternalTools({ schoolToolRef: contextExternalTool.schoolToolRef, context: contextExternalTool.contextRef, }); + // Only leave tools that are not the currently handled tool itself (for updates) or ones with the same name + duplicate = duplicate.filter( + (duplicateTool) => + duplicateTool.id !== contextExternalTool.id && duplicateTool.displayName === contextExternalTool.displayName + ); + if (duplicate.length > 0) { - throw new UnprocessableEntityException('Tool is already assigned.'); + throw new ValidationError( + `tool_with_name_exists: A tool with the same name is already assigned to this course. Tool names must be unique within a course.` + ); } } } diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts index 23df19fcbb3..1f2a1496fac 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts @@ -1,23 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO, ContextRef, Permission, SchoolExternalToolDO } from '@shared/domain'; import { ContextExternalToolRepo } from '@shared/repo'; import { - contextExternalToolDOFactory, + contextExternalToolFactory, schoolDOFactory, - schoolExternalToolDOFactory, + schoolExternalToolFactory, } from '@shared/testing/factory/domainobject'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationService } from '@src/modules/authorization'; +import { ToolContextType } from '../../common/enum'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextExternalTool, ContextRef } from '../domain'; import { ContextExternalToolService } from './context-external-tool.service'; -import { ToolContextType } from '../../common/interface'; describe('ContextExternalToolService', () => { let module: TestingModule; let service: ContextExternalToolService; let contextExternalToolRepo: DeepMocked; - let authorizationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -36,7 +36,6 @@ describe('ContextExternalToolService', () => { service = module.get(ContextExternalToolService); contextExternalToolRepo = module.get(ContextExternalToolRepo); - authorizationService = module.get(AuthorizationService); }); afterAll(async () => { @@ -47,10 +46,10 @@ describe('ContextExternalToolService', () => { jest.resetAllMocks(); }); - describe('findContextExternalTools is called', () => { + describe('findContextExternalTools', () => { describe('when query is given', () => { const setup = () => { - const contextExternalTools: ContextExternalToolDO[] = contextExternalToolDOFactory.buildList(2); + const contextExternalTools: ContextExternalTool[] = contextExternalToolFactory.buildList(2); contextExternalToolRepo.find.mockResolvedValue(contextExternalTools); @@ -62,22 +61,22 @@ describe('ContextExternalToolService', () => { it('should return an array of contextExternalTools', async () => { const { contextExternalTools } = setup(); - const result: ContextExternalToolDO[] = await service.findContextExternalTools({}); + const result: ContextExternalTool[] = await service.findContextExternalTools({}); expect(result).toEqual(contextExternalTools); }); }); }); - describe('deleteBySchoolExternalToolId is called', () => { + describe('deleteBySchoolExternalToolId', () => { describe('when schoolExternalToolId is given', () => { const setup = () => { - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const schoolExternalToolId = schoolExternalTool.id as string; - const contextExternalTool1: ContextExternalToolDO = contextExternalToolDOFactory + const contextExternalTool1: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalToolId) .buildWithId(); - const contextExternalTool2: ContextExternalToolDO = contextExternalToolDOFactory + const contextExternalTool2: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalToolId) .buildWithId(); contextExternalToolRepo.find.mockResolvedValueOnce([contextExternalTool1, contextExternalTool2]); @@ -110,11 +109,11 @@ describe('ContextExternalToolService', () => { }); }); - describe('createContextExternalTool is called', () => { + describe('saveContextExternalTool', () => { describe('when contextExternalTool is given', () => { const setup = () => { jest.useFakeTimers().setSystemTime(new Date('2023-01-01')); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); return { contextExternalTool, @@ -124,21 +123,21 @@ describe('ContextExternalToolService', () => { it('should call contextExternalToolRepo ', async () => { const { contextExternalTool } = setup(); - await service.createContextExternalTool(contextExternalTool); + await service.saveContextExternalTool(contextExternalTool); expect(contextExternalToolRepo.save).toHaveBeenCalledWith(contextExternalTool); }); }); }); - describe('getContextExternalToolById is called', () => { + describe('getContextExternalToolById', () => { describe('when contextExternalToolId is given', () => { const setup = () => { const schoolId: string = schoolDOFactory.buildWithId().id as string; - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ schoolId, }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory + const contextExternalTool: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) .build(); @@ -151,32 +150,32 @@ describe('ContextExternalToolService', () => { it('should return a contextExternalTool', async () => { const { contextExternalTool } = setup(); - contextExternalToolRepo.find.mockResolvedValue([contextExternalTool]); - const result: ContextExternalToolDO = await service.getContextExternalToolById( - contextExternalTool.id as string - ); + const result: ContextExternalTool = await service.getContextExternalToolById(contextExternalTool.id as string); expect(result).toEqual(contextExternalTool); }); }); describe('when contextExternalTool could not be found', () => { + const setup = () => { + contextExternalToolRepo.findById.mockRejectedValue(new NotFoundException()); + }; + it('should throw a not found exception', async () => { - const id = 'someId'; - contextExternalToolRepo.find.mockResolvedValue([]); + setup(); - const func = () => service.getContextExternalToolById(id); + const func = () => service.getContextExternalToolById('unknownContextExternalToolId'); - await expect(func()).rejects.toThrow(new NotFoundException(`ContextExternalTool with id ${id} not found`)); + await expect(func()).rejects.toThrow(NotFoundException); }); }); }); - describe('deleteContextExternalTool is called', () => { + describe('deleteContextExternalTool', () => { describe('when contextExternalToolId is given', () => { const setup = () => { - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); return { contextExternalTool, @@ -193,82 +192,7 @@ describe('ContextExternalToolService', () => { }); }); - describe('ensureContextPermissions is called', () => { - const setup = () => { - const userId = 'userId'; - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); - - return { - userId, - contextExternalTool, - }; - }; - - describe('context external tool has a id', () => { - it('should check permission by reference for context external tool itself', async () => { - const { userId, contextExternalTool } = setup(); - - await service.ensureContextPermissions(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - }); - - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, - AuthorizableReferenceType.ContextExternalTool, - contextExternalTool.id, - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - } - ); - }); - - it('should check permission by reference for the dependent context of the context external tool', async () => { - const { userId, contextExternalTool } = setup(); - contextExternalTool.contextRef.type = ToolContextType.COURSE; - - await service.ensureContextPermissions(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - }); - - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, - AuthorizableReferenceType.Course, - contextExternalTool.contextRef.id, - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - } - ); - }); - }); - - describe('context external tool has no id yet', () => { - it('should skip permission check for context external tool', async () => { - const { userId, contextExternalTool } = setup(); - const contextExternalToolWithoutId = new ContextExternalToolDO({ ...contextExternalTool, id: '' }); - - await service.ensureContextPermissions(userId, contextExternalToolWithoutId, { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - }); - - expect(authorizationService.checkPermissionByReferences).not.toHaveBeenCalledWith( - userId, - AuthorizableReferenceType.ContextExternalTool, - contextExternalToolWithoutId.id, - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - } - ); - }); - }); - }); - - describe('getContextExternalToolsForContext is called', () => { + describe('getContextExternalToolsForContext', () => { describe('when contextType and contextId are given', () => { it('should call the repository', async () => { const contextRef: ContextRef = new ContextRef({ type: ToolContextType.COURSE, id: 'contextId' }); @@ -283,12 +207,12 @@ describe('ContextExternalToolService', () => { }); it('should return context external tools', async () => { - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build(); - contextExternalToolRepo.find.mockResolvedValue([contextExternalToolDO]); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + contextExternalToolRepo.find.mockResolvedValue([contextExternalTool]); - const result: ContextExternalToolDO[] = await service.findAllByContext(contextExternalToolDO.contextRef); + const result: ContextExternalTool[] = await service.findAllByContext(contextExternalTool.contextRef); - expect(result).toEqual([contextExternalToolDO]); + expect(result).toEqual([contextExternalTool]); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts index 711c7c44b51..011e6db2f7a 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts @@ -1,55 +1,33 @@ -import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ContextRef, EntityId } from '@shared/domain'; -import { ContextExternalToolDO } from '@shared/domain/domainobject/tool'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; import { ContextExternalToolRepo } from '@shared/repo'; -import { AuthorizableReferenceType, AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; -import { ContextTypeMapper } from '../../common/mapper'; +import { ContextExternalTool, ContextRef } from '../domain'; @Injectable() export class ContextExternalToolService { - constructor( - private readonly contextExternalToolRepo: ContextExternalToolRepo, - @Inject(forwardRef(() => AuthorizationService)) - private readonly authorizationService: AuthorizationService - ) {} + constructor(private readonly contextExternalToolRepo: ContextExternalToolRepo) {} - async findContextExternalTools(query: ContextExternalToolQuery): Promise { - const contextExternalTools: ContextExternalToolDO[] = await this.contextExternalToolRepo.find(query); + async findContextExternalTools(query: ContextExternalToolQuery): Promise { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find(query); return contextExternalTools; } - async getContextExternalToolById(contextExternalToolId: EntityId): Promise { - const contextExternalTools: ContextExternalToolDO[] = await this.contextExternalToolRepo.find({ - id: contextExternalToolId, - }); - - if (contextExternalTools.length === 0) { - throw new NotFoundException(`ContextExternalTool with id ${contextExternalToolId} not found`); - } + async getContextExternalToolById(contextExternalToolId: EntityId): Promise { + const tool: ContextExternalTool = await this.contextExternalToolRepo.findById(contextExternalToolId); - return contextExternalTools[0]; + return tool; } - async createContextExternalTool(contextExternalTool: ContextExternalToolDO): Promise { - const newContextExternalTool: ContextExternalToolDO = new ContextExternalToolDO({ - displayName: contextExternalTool.displayName, - contextRef: contextExternalTool.contextRef, - toolVersion: contextExternalTool.toolVersion, - parameters: contextExternalTool.parameters, - schoolToolRef: contextExternalTool.schoolToolRef, - }); - - const createdContextExternalTool: ContextExternalToolDO = await this.contextExternalToolRepo.save( - newContextExternalTool - ); + async saveContextExternalTool(contextExternalTool: ContextExternalTool): Promise { + const savedContextExternalTool: ContextExternalTool = await this.contextExternalToolRepo.save(contextExternalTool); - return createdContextExternalTool; + return savedContextExternalTool; } async deleteBySchoolExternalToolId(schoolExternalToolId: EntityId) { - const contextExternalTools: ContextExternalToolDO[] = await this.contextExternalToolRepo.find({ + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ schoolToolRef: { schoolToolId: schoolExternalToolId, }, @@ -58,34 +36,12 @@ export class ContextExternalToolService { await this.contextExternalToolRepo.delete(contextExternalTools); } - async deleteContextExternalTool(contextExternalTool: ContextExternalToolDO): Promise { + async deleteContextExternalTool(contextExternalTool: ContextExternalTool): Promise { await this.contextExternalToolRepo.delete(contextExternalTool); } - public async ensureContextPermissions( - userId: EntityId, - contextExternalToolDO: ContextExternalToolDO, - context: AuthorizationContext - ): Promise { - if (contextExternalToolDO.id) { - await this.authorizationService.checkPermissionByReferences( - userId, - AuthorizableReferenceType.ContextExternalTool, - contextExternalToolDO.id, - context - ); - } - - await this.authorizationService.checkPermissionByReferences( - userId, - ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(contextExternalToolDO.contextRef.type), - contextExternalToolDO.contextRef.id, - context - ); - } - - async findAllByContext(contextRef: ContextRef): Promise { - const contextExternalTools: ContextExternalToolDO[] = await this.contextExternalToolRepo.find({ + async findAllByContext(contextRef: ContextRef): Promise { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ context: contextRef, }); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index 4f3c148b91e..7dcdea9d16b 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -1,13 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ForbiddenException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO, EntityId, Permission } from '@shared/domain'; -import { contextExternalToolDOFactory, setupEntities } from '@shared/testing'; +import { EntityId, Permission, User } from '@shared/domain'; +import { contextExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { Action, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { ForbiddenLoggableException } from '@src/modules/authorization/errors/forbidden.loggable-exception'; -import { Action } from '@src/modules/authorization'; -import { ContextExternalToolUc } from './context-external-tool.uc'; +import { ToolContextType } from '../../common/enum'; +import { ContextExternalTool } from '../domain'; import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; -import { ToolContextType } from '../../common/interface'; +import { ContextExternalToolUc } from './context-external-tool.uc'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ContextExternalToolUc', () => { let module: TestingModule; @@ -15,6 +19,8 @@ describe('ContextExternalToolUc', () => { let contextExternalToolService: DeepMocked; let contextExternalToolValidationService: DeepMocked; + let toolPermissionHelper: DeepMocked; + let authorizationService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -33,12 +39,22 @@ describe('ContextExternalToolUc', () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(ContextExternalToolUc); contextExternalToolService = module.get(ContextExternalToolService); contextExternalToolValidationService = module.get(ContextExternalToolValidationService); + toolPermissionHelper = module.get(ToolPermissionHelper); + authorizationService = module.get(AuthorizationService); }); afterAll(async () => { @@ -49,12 +65,12 @@ describe('ContextExternalToolUc', () => { jest.resetAllMocks(); }); - describe('createContextExternalTool is called', () => { + describe('createContextExternalTool', () => { describe('when contextExternalTool is given and user has permission ', () => { const setup = () => { const userId: EntityId = 'userId'; - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ displayName: 'Course', contextRef: { id: 'contextId', @@ -62,8 +78,8 @@ describe('ContextExternalToolUc', () => { }, }); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(Promise.resolve()); - contextExternalToolValidationService.validate.mockResolvedValue(Promise.resolve()); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(Promise.resolve()); + contextExternalToolService.saveContextExternalTool.mockResolvedValue(contextExternalTool); return { contextExternalTool, @@ -76,7 +92,7 @@ describe('ContextExternalToolUc', () => { await uc.createContextExternalTool(userId, contextExternalTool); - expect(contextExternalToolService.createContextExternalTool).toHaveBeenCalledWith(contextExternalTool); + expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(contextExternalTool); }); it('should call contextExternalToolService to ensure permissions', async () => { @@ -84,10 +100,11 @@ describe('ContextExternalToolUc', () => { await uc.createContextExternalTool(userId, contextExternalTool); - expect(contextExternalToolService.ensureContextPermissions).toHaveBeenCalledWith(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.write, - }); + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); }); it('should call contextExternalToolValidationService', async () => { @@ -97,17 +114,231 @@ describe('ContextExternalToolUc', () => { expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); }); + + it('should return the saved object', async () => { + const { contextExternalTool, userId } = setup(); + + const result = await uc.createContextExternalTool(userId, contextExternalTool); + + expect(result).toEqual(contextExternalTool); + }); + }); + + describe('when the user does not have permission', () => { + const setup = () => { + const userId: EntityId = 'userId'; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + const error = new ForbiddenException(); + + toolPermissionHelper.ensureContextPermissions.mockRejectedValue(error); + + return { + contextExternalTool, + userId, + error, + }; + }; + + it('should return forbidden and not save', async () => { + const { contextExternalTool, userId, error } = setup(); + + const func = () => uc.createContextExternalTool(userId, contextExternalTool); + + await expect(func).rejects.toThrow(error); + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + + describe('when the validation fails', () => { + const setup = () => { + const userId: EntityId = 'userId'; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + const error = new UnprocessableEntityException(); + + contextExternalToolValidationService.validate.mockRejectedValue(error); + + return { + contextExternalTool, + userId, + error, + }; + }; + + it('should return UnprocessableEntity and not save', async () => { + const { contextExternalTool, userId, error } = setup(); + + const func = () => uc.createContextExternalTool(userId, contextExternalTool); + + await expect(func).rejects.toThrow(error); + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + }); + + describe('updateContextExternalTool', () => { + describe('when contextExternalTool is given and user has permission ', () => { + const setup = () => { + const userId: EntityId = 'userId'; + + const contextExternalToolId = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + { + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }, + contextExternalToolId + ); + + contextExternalToolService.saveContextExternalTool.mockResolvedValue(contextExternalTool); + + return { + contextExternalTool, + contextExternalToolId, + userId, + }; + }; + + it('should call contextExternalToolService', async () => { + const { contextExternalTool, contextExternalToolId, userId } = setup(); + + await uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should call contextExternalToolService to ensure permissions', async () => { + const { contextExternalTool, contextExternalToolId, userId } = setup(); + + await uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); + }); + + it('should call contextExternalToolValidationService', async () => { + const { contextExternalTool, contextExternalToolId, userId } = setup(); + + await uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should return the saved object', async () => { + const { contextExternalTool, contextExternalToolId, userId } = setup(); + + const result = await uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + expect(result).toEqual(contextExternalTool); + }); + }); + + describe('when the user does not have permission', () => { + const setup = () => { + const userId: EntityId = 'userId'; + + const contextExternalToolId = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + { + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }, + contextExternalToolId + ); + + const error = new ForbiddenException(); + + toolPermissionHelper.ensureContextPermissions.mockRejectedValue(error); + + return { + contextExternalTool, + contextExternalToolId, + userId, + error, + }; + }; + + it('should return forbidden and not save', async () => { + const { contextExternalTool, contextExternalToolId, userId, error } = setup(); + + const func = () => uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + await expect(func).rejects.toThrow(error); + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + + describe('when the validation fails', () => { + const setup = () => { + const userId: EntityId = 'userId'; + + const contextExternalToolId = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + { + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }, + contextExternalToolId + ); + + const error = new UnprocessableEntityException(); + + contextExternalToolValidationService.validate.mockRejectedValue(error); + + return { + contextExternalTool, + contextExternalToolId, + userId, + error, + }; + }; + + it('should return UnprocessableEntity and not save', async () => { + const { contextExternalTool, contextExternalToolId, userId, error } = setup(); + + const func = () => uc.updateContextExternalTool(userId, contextExternalToolId, contextExternalTool); + + await expect(func).rejects.toThrow(error); + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); }); }); - describe('deleteContextExternalTool is called', () => { + describe('deleteContextExternalTool', () => { describe('when contextExternalTool is given and user has permission ', () => { const setup = () => { const userId: EntityId = 'userId'; - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); return { @@ -130,22 +361,24 @@ describe('ContextExternalToolUc', () => { await uc.deleteContextExternalTool(userId, contextExternalToolId); - expect(contextExternalToolService.ensureContextPermissions).toHaveBeenCalledWith(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.write, - }); + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); }); }); }); - describe('getContextExternalToolsForContext is called', () => { + describe('getContextExternalToolsForContext', () => { describe('when parameters are given and user has permission ', () => { const setup = () => { const userId: EntityId = 'userId'; + const user: User = userFactory.build(); const contextId: EntityId = 'contextId'; const contextType: ToolContextType = ToolContextType.COURSE; - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ displayName: 'Course', contextRef: { id: 'contextId', @@ -154,11 +387,13 @@ describe('ContextExternalToolUc', () => { }); contextExternalToolService.findAllByContext.mockResolvedValue([contextExternalTool]); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(Promise.resolve()); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.hasPermission.mockReturnValue(true); return { contextExternalTool, userId, + user, contextId, contextType, }; @@ -169,41 +404,172 @@ describe('ContextExternalToolUc', () => { await uc.getContextExternalToolsForContext(userId, contextType, contextId); - expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith({ id: contextId, type: contextType }); + expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith({ + id: contextId, + type: contextType, + }); }); - it('should call contextExternalToolService to ensure permissions', async () => { - const { userId, contextType, contextId, contextExternalTool } = setup(); + it('should call Authorization Service to ensure permissions', async () => { + const { userId, user, contextType, contextId, contextExternalTool } = setup(); await uc.getContextExternalToolsForContext(userId, contextType, contextId); - expect(contextExternalToolService.ensureContextPermissions).toHaveBeenCalledWith(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.read, - }); + expect(authorizationService.hasPermission).toHaveBeenCalledWith( + user, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) + ); }); + }); - it('should handle ForbiddenLoggableException and not include the tool in the response', async () => { - const { userId, contextType, contextId } = setup(); + describe('when permission is not granted', () => { + const setup = () => { + const userId: EntityId = 'userId'; + const contextId: EntityId = 'contextId'; + const contextType: ToolContextType = ToolContextType.COURSE; - contextExternalToolService.ensureContextPermissions.mockRejectedValue( - new ForbiddenLoggableException(userId, 'contextExternalTool', { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.read, - }) - ); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + contextExternalToolService.findAllByContext.mockResolvedValue([contextExternalTool]); + authorizationService.hasPermission.mockReturnValue(false); + + return { + userId, + contextId, + contextType, + }; + }; + + it('should not include the tool in the response', async () => { + const { userId, contextType, contextId } = setup(); const tools = await uc.getContextExternalToolsForContext(userId, contextType, contextId); expect(tools).toEqual([]); }); + }); + + describe('when some other error is thrown', () => { + const setup = () => { + const userId: EntityId = 'userId'; + const contextId: EntityId = 'contextId'; + const contextType: ToolContextType = ToolContextType.COURSE; + + contextExternalToolService.findAllByContext.mockRejectedValue(new Error()); + + return { + userId, + contextId, + contextType, + }; + }; it('should rethrow any exception other than ForbiddenLoggableException', async () => { const { userId, contextType, contextId } = setup(); - contextExternalToolService.ensureContextPermissions.mockRejectedValue(new Error()); + const func = () => uc.getContextExternalToolsForContext(userId, contextType, contextId); + + await expect(func()).rejects.toThrow(Error); + }); + }); + }); + + describe('getContextExternalTool', () => { + describe('when right permission, context and id is given', () => { + const setup = () => { + const userId: EntityId = 'userId'; + const contextId: EntityId = 'contextId'; + const contextType: ToolContextType = ToolContextType.COURSE; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(Promise.resolve()); + + return { + contextExternalTool, + userId, + contextId, + contextType, + }; + }; + + it('should call contextExternalToolService to ensure permission ', async () => { + const { contextExternalTool, userId } = setup(); + + await uc.getContextExternalTool(userId, contextExternalTool.id as string); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) + ); + }); + + it('should call contextExternalToolService to get contextExternalTool ', async () => { + const { contextExternalTool, userId } = setup(); + + await uc.getContextExternalTool(userId, contextExternalTool.id as string); + + expect(contextExternalToolService.getContextExternalToolById).toHaveBeenCalledWith(contextExternalTool.id); + }); + }); + + describe('when currentUser has no permission', () => { + const setup = () => { + const userId: EntityId = 'userId'; + const contextId: EntityId = 'contextId'; + const contextType: ToolContextType = ToolContextType.COURSE; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + toolPermissionHelper.ensureContextPermissions.mockRejectedValue( + new ForbiddenLoggableException( + userId, + 'contextExternalTool', + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) + ) + ); - await expect(uc.getContextExternalToolsForContext(userId, contextType, contextId)).rejects.toThrow(Error); + return { + contextExternalTool, + userId, + contextId, + contextType, + }; + }; + + it('should throw forbiddenLoggableException', async () => { + const { contextExternalTool, userId } = setup(); + + const func = () => uc.getContextExternalTool(userId, contextExternalTool.id as string); + + await expect(func).rejects.toThrow( + new ForbiddenLoggableException(userId, 'contextExternalTool', { + requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], + action: Action.read, + }) + ); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index 915cf2634d5..903b8197251 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -1,48 +1,73 @@ import { Injectable } from '@nestjs/common'; -import { ContextExternalToolDO, ContextRef, EntityId, Permission } from '@shared/domain'; -import { Action } from '@src/modules/authorization'; +import { EntityId, Permission, User } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { LegacyLogger } from '@src/core/logger'; -import { ForbiddenLoggableException } from '@src/modules/authorization/errors/forbidden.loggable-exception'; import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; -import { ContextExternalTool } from './dto/context-external-tool.types'; -import { ToolContextType } from '../../common/interface'; +import { ContextExternalToolDto } from './dto/context-external-tool.types'; +import { ContextExternalTool, ContextRef } from '../domain'; +import { ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ContextExternalToolUc { constructor( + private readonly toolPermissionHelper: ToolPermissionHelper, private readonly contextExternalToolService: ContextExternalToolService, private readonly contextExternalToolValidationService: ContextExternalToolValidationService, + private readonly authorizationService: AuthorizationService, private readonly logger: LegacyLogger ) {} async createContextExternalTool( userId: EntityId, - contextExternalTool: ContextExternalTool - ): Promise { - const contextExternalToolDO = new ContextExternalToolDO(contextExternalTool); + contextExternalToolDto: ContextExternalToolDto + ): Promise { + const contextExternalTool = new ContextExternalTool(contextExternalToolDto); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); - await this.contextExternalToolService.ensureContextPermissions(userId, contextExternalToolDO, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.write, - }); + await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); - await this.contextExternalToolValidationService.validate(contextExternalTool); + await this.contextExternalToolValidationService.validate(contextExternalToolDto); - const createdTool: ContextExternalToolDO = await this.contextExternalToolService.createContextExternalTool( - contextExternalToolDO + const createdTool: ContextExternalTool = await this.contextExternalToolService.saveContextExternalTool( + contextExternalTool ); return createdTool; } + async updateContextExternalTool( + userId: EntityId, + contextExternalToolId: EntityId, + contextExternalToolDto: ContextExternalToolDto + ): Promise { + const contextExternalTool: ContextExternalTool = new ContextExternalTool(contextExternalToolDto); + + await this.toolPermissionHelper.ensureContextPermissions( + userId, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); + + const updated: ContextExternalTool = new ContextExternalTool({ + ...contextExternalTool, + id: contextExternalToolId, + }); + + await this.contextExternalToolValidationService.validate(updated); + + const saved: ContextExternalTool = await this.contextExternalToolService.saveContextExternalTool(updated); + + return saved; + } + async deleteContextExternalTool(userId: EntityId, contextExternalToolId: EntityId): Promise { - const tool: ContextExternalToolDO = await this.contextExternalToolService.getContextExternalToolById( + const tool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( contextExternalToolId ); - await this.contextExternalToolService.ensureContextPermissions(userId, tool, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.write, - }); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + + await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); const promise: Promise = this.contextExternalToolService.deleteContextExternalTool(tool); @@ -50,40 +75,35 @@ export class ContextExternalToolUc { } async getContextExternalToolsForContext(userId: EntityId, contextType: ToolContextType, contextId: string) { - const tools: ContextExternalToolDO[] = await this.contextExternalToolService.findAllByContext( + const tools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( new ContextRef({ id: contextId, type: contextType }) ); - const toolsWithPermission: ContextExternalToolDO[] = await this.filterToolsWithPermissions(userId, tools); + const toolsWithPermission: ContextExternalTool[] = await this.filterToolsWithPermissions(userId, tools); return toolsWithPermission; } + async getContextExternalTool(userId: EntityId, contextToolId: EntityId) { + const tool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById(contextToolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + + await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); + + return tool; + } + private async filterToolsWithPermissions( userId: EntityId, - tools: ContextExternalToolDO[] - ): Promise { - const toolPromises = tools.map(async (tool) => { - try { - await this.contextExternalToolService.ensureContextPermissions(userId, tool, { - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - action: Action.read, - }); - - return tool; - } catch (error) { - if (error instanceof ForbiddenLoggableException) { - this.logger.debug(`User ${userId} does not have permission for tool ${tool.id ?? 'undefined'}`); - return null; - } - throw error; - } - }); + tools: ContextExternalTool[] + ): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); - const toolsWithPermission = await Promise.all(toolPromises); - const filteredTools: ContextExternalToolDO[] = toolsWithPermission.filter( - (tool) => tool !== null - ) as ContextExternalToolDO[]; - return filteredTools; + const toolsWithPermission: ContextExternalTool[] = tools.filter((tool) => + this.authorizationService.hasPermission(user, tool, context) + ); + + return toolsWithPermission; } } diff --git a/apps/server/src/modules/tool/context-external-tool/uc/dto/context-external-tool.types.ts b/apps/server/src/modules/tool/context-external-tool/uc/dto/context-external-tool.types.ts index 4d0520af813..af595e96779 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/dto/context-external-tool.types.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/dto/context-external-tool.types.ts @@ -1,7 +1,7 @@ -import { ContextExternalToolProps } from '@shared/domain/domainobject/tool/context-external-tool.do'; -import { ContextRef, SchoolExternalToolRefDO } from '@shared/domain'; +import { ContextExternalToolProps, ContextRef } from '../../domain'; +import { SchoolExternalToolRefDO } from '../../../school-external-tool/domain'; -export type ContextExternalTool = ContextExternalToolProps; +export type ContextExternalToolDto = ContextExternalToolProps; export type ContextExternalToolQuery = { id?: string; diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts index 5ebb892c849..82d72d7e86f 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts @@ -1,66 +1,51 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Account, Course, Permission, School, User } from '@shared/domain'; import { - Course, - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - ExternalTool, - Permission, - Role, - RoleName, - School, - SchoolExternalTool, - User, -} from '@shared/domain'; -import { + accountFactory, + contextExternalToolEntityFactory, courseFactory, - externalToolFactory, - mapUserToCurrentUser, - roleFactory, - schoolExternalToolFactory, + customParameterFactory, + externalToolEntityFactory, + schoolExternalToolEntityFactory, schoolFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { ICurrentUser, JwtAuthGuard } from '@src/modules/authentication'; import { ServerTestModule } from '@src/modules/server'; +import { CustomParameterTypeParams } from '@src/modules/tool/common/enum'; +import { Response } from 'supertest'; +import { CustomParameterLocationParams, CustomParameterScopeTypeParams } from '../../../common/enum'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { ExternalToolEntity } from '../../entity'; import { - CustomParameterLocationParams, - CustomParameterScopeTypeParams, - CustomParameterTypeParams, -} from '../../../common/interface'; -import { CustomParameter } from '../../uc'; -import { SchoolToolConfigurationListResponse } from '../../../school-external-tool/controller/dto'; -import { CustomParameterResponse, ToolConfigurationListResponse } from '../dto'; - -describe('ToolSchoolController (API)', () => { + ContextExternalToolConfigurationTemplateListResponse, + ContextExternalToolConfigurationTemplateResponse, + SchoolExternalToolConfigurationTemplateListResponse, + SchoolExternalToolConfigurationTemplateResponse, +} from '../dto'; + +describe('ToolConfigurationController (API)', () => { let app: INestApplication; let em: EntityManager; let orm: MikroORM; - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleRef.createNestApplication(); await app.init(); em = app.get(EntityManager); orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, 'tools'); }); afterAll(async () => { @@ -71,30 +56,57 @@ describe('ToolSchoolController (API)', () => { await orm.getSchemaGenerator().clearDatabase(); }); - describe('[GET] tools/available/context/:id', () => { + describe('[GET] tools/:contextType/:contextId/available-tools', () => { describe('when the user is not authorized', () => { const setup = async () => { const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [] }); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); - const course: Course = courseFactory.buildWithId({ teachers: [user], school }); + const course: Course = courseFactory.buildWithId({ teachers: [studentUser], school }); - await em.persistAndFlush([user, school, course]); + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [globalParameter, schoolParameter, contextParameter], + }); + + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); + + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); + + await em.persistAndFlush([ + school, + course, + studentUser, + studentAccount, + externalTool, + schoolExternalTool, + contextExternalTool, + ]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + return { - user, - school, course, + externalTool, + schoolExternalTool, + contextParameter, + loggedInClient, }; }; it('should return a forbidden status', async () => { - const { user, course } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { course, loggedInClient } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/course/${course.id}`); + const response: Response = await loggedInClient.get(`course/${course.id}/available-tools`); expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); @@ -102,49 +114,67 @@ describe('ToolSchoolController (API)', () => { describe('when tools are available for a course', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); - const course: Course = courseFactory.buildWithId({ teachers: [user], school }); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + logoBase64: 'logo', + parameters: [globalParameter, schoolParameter, contextParameter], + }); + externalTool.logoUrl = `http://localhost:3030/api/v3/tools/external-tools/${externalTool.id}/logo`; - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, tool: externalTool, }); - await em.persistAndFlush([user, school, course, adminRole, externalTool, schoolExternalTool]); + await em.persistAndFlush([school, course, teacherUser, teacherAccount, externalTool, schoolExternalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - user, - school, course, externalTool, schoolExternalTool, + contextParameter, + loggedInClient, }; }; - it('should return an array of available tools', async () => { - const { user, course, externalTool, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(user); + it('should return an array of available tools with parameters of scope context', async () => { + const { course, externalTool, contextParameter, schoolExternalTool, loggedInClient } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/course/${course.id}`); + const response: Response = await loggedInClient.get(`course/${course.id}/available-tools`); - expect(response.body).toEqual({ + expect(response.body).toEqual({ data: [ { - id: externalTool.id, + externalToolId: externalTool.id, + schoolExternalToolId: schoolExternalTool.id, name: externalTool.name, logoUrl: externalTool.logoUrl, - schoolToolId: schoolExternalTool.id, + parameters: [ + { + name: contextParameter.name, + displayName: contextParameter.displayName, + isOptional: contextParameter.isOptional, + defaultValue: contextParameter.default, + description: contextParameter.description, + regex: contextParameter.regex, + regexComment: contextParameter.regexComment, + type: CustomParameterTypeParams.STRING, + scope: CustomParameterScopeTypeParams.CONTEXT, + location: CustomParameterLocationParams.BODY, + }, + ], + version: externalTool.version, }, ], }); @@ -153,64 +183,74 @@ describe('ToolSchoolController (API)', () => { describe('when no tools are available for a course', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({}, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); - const course: Course = courseFactory.buildWithId({ teachers: [user], school }); + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - await em.persistAndFlush([user, school, course, adminRole, externalTool]); + await em.persistAndFlush([teacherUser, school, course, teacherAccount, externalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - user, - school, + loggedInClient, course, - externalTool, }; }; it('should return an empty array', async () => { - const { user, course } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, course } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/course/${course.id}`); + const response: Response = await loggedInClient.get(`course/${course.id}/available-tools`); - expect(response.body).toEqual({ + expect(response.body).toEqual({ data: [], }); }); }); }); - describe('[GET] tools/available/school/:id', () => { + describe('[GET] tools/school/:schoolId/available-tools', () => { describe('when the user is not authorized', () => { const setup = async () => { const school: School = schoolFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [] }); + const account: Account = accountFactory.buildWithId({ userId: user.id }); + + const course: Course = courseFactory.buildWithId({ teachers: [user], school }); + + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [globalParameter, schoolParameter, contextParameter], + }); + + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - await em.persistAndFlush([user, school]); + await em.persistAndFlush([user, account, course, school, externalTool, schoolExternalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(account); + return { - user, + loggedInClient, school, }; }; it('should return a forbidden status', async () => { - const { user, school } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, school } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/school/${school.id}`); + const response: Response = await loggedInClient.get(`school/${school.id}/available-tools`); expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); @@ -218,39 +258,56 @@ describe('ToolSchoolController (API)', () => { describe('when tools are available for a school', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.SCHOOL_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + logoBase64: 'logo', + parameters: [globalParameter, schoolParameter, contextParameter], + }); + externalTool.logoUrl = `http://localhost:3030/api/v3/tools/external-tools/${externalTool.id}/logo`; - await em.persistAndFlush([user, school, adminRole, externalTool]); + await em.persistAndFlush([adminUser, school, adminAccount, externalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + return { - user, + loggedInClient, school, externalTool, + schoolParameter, }; }; - it('should return a list of available external tools', async () => { - const { user, school, externalTool } = await setup(); - currentUser = mapUserToCurrentUser(user); + it('should return a list of available external tools with parameters of scope school', async () => { + const { loggedInClient, school, externalTool, schoolParameter } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/school/${school.id}`); + const response: Response = await loggedInClient.get(`school/${school.id}/available-tools`); - expect(response.body).toEqual({ + expect(response.body).toEqual({ data: [ { - id: externalTool.id, + externalToolId: externalTool.id, name: externalTool.name, logoUrl: externalTool.logoUrl, + parameters: [ + { + name: schoolParameter.name, + displayName: schoolParameter.displayName, + isOptional: schoolParameter.isOptional, + defaultValue: schoolParameter.default, + description: schoolParameter.description, + regex: schoolParameter.regex, + regexComment: schoolParameter.regexComment, + type: CustomParameterTypeParams.STRING, + scope: CustomParameterScopeTypeParams.SCHOOL, + location: CustomParameterLocationParams.BODY, + }, + ], + version: externalTool.version, }, ], }); @@ -259,60 +316,65 @@ describe('ToolSchoolController (API)', () => { describe('when no tools are available for a school', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.SCHOOL_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); - await em.persistAndFlush([user, school, adminRole]); + await em.persistAndFlush([adminUser, school, adminAccount]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + return { - user, + loggedInClient, school, }; }; it('should return an empty array', async () => { - const { user, school } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, school } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/available/school/${school.id}`); + const response: Response = await loggedInClient.get(`school/${school.id}/available-tools`); - expect(response.body).toEqual({ + expect(response.body).toEqual({ data: [], }); }); }); }); - describe('GET tools/:toolId/configuration', () => { + describe('GET tools/school-external-tools/:schoolExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { const school: School = schoolFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [] }); + const account: Account = accountFactory.buildWithId({ userId: user.id }); + + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - await em.persistAndFlush([user, school, externalTool]); + await em.persistAndFlush([user, account, school, externalTool, schoolExternalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(account); + return { - user, - externalTool, + loggedInClient, + schoolExternalTool, }; }; it('should return a forbidden status', async () => { - const { user, externalTool } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, schoolExternalTool } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/${externalTool.id}/configuration`); + const response: Response = await loggedInClient.get( + `school-external-tools/${schoolExternalTool.id}/configuration-template` + ); expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); @@ -320,94 +382,99 @@ describe('ToolSchoolController (API)', () => { describe('when tool is not hidden', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.SCHOOL_TOOL_ADMIN], + const school: School = schoolFactory.buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [globalParameter, schoolParameter, contextParameter], }); - const school: School = schoolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); - - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - const customParameterResponse: CustomParameterResponse[] = [ - { - name: 'name', - displayName: 'User Friendly Name', - description: 'This is a mock parameter.', - defaultValue: 'default', - location: CustomParameterLocationParams.PATH, - scope: CustomParameterScopeTypeParams.SCHOOL, - type: CustomParameterTypeParams.STRING, - regex: 'regex', - regexComment: 'mockComment', - isOptional: false, - }, - ]; - - await em.persistAndFlush([user, school, adminRole, externalTool]); + await em.persistAndFlush([adminUser, school, adminAccount, externalTool, schoolExternalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + return { - user, + loggedInClient, school, externalTool, - customParameterResponse, + schoolParameter, + schoolExternalTool, }; }; - it('should return a tool', async () => { - const { user, externalTool, customParameterResponse } = await setup(); - currentUser = mapUserToCurrentUser(user); + it('should return a tool with parameter with scope school', async () => { + const { loggedInClient, schoolExternalTool, externalTool, schoolParameter } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/${externalTool.id}/configuration`); + const response: Response = await loggedInClient.get( + `school-external-tools/${schoolExternalTool.id}/configuration-template` + ); - expect(response.body).toEqual({ - id: externalTool.id, + expect(response.body).toEqual({ + externalToolId: externalTool.id, name: externalTool.name, logoUrl: externalTool.logoUrl, version: externalTool.version, - parameters: customParameterResponse, + parameters: [ + { + name: schoolParameter.name, + displayName: schoolParameter.displayName, + isOptional: schoolParameter.isOptional, + defaultValue: schoolParameter.default, + description: schoolParameter.description, + regex: schoolParameter.regex, + regexComment: schoolParameter.regexComment, + type: CustomParameterTypeParams.STRING, + scope: CustomParameterScopeTypeParams.SCHOOL, + location: CustomParameterLocationParams.BODY, + }, + ], }); }); }); describe('when tool is hidden', () => { const setup = async () => { - const adminRole: Role = roleFactory.buildWithId({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.SCHOOL_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [adminRole] }); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: true }); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ isHidden: true }); - await em.persistAndFlush([user, school, adminRole, externalTool]); + await em.persistAndFlush([adminUser, school, adminAccount, externalTool]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + return { - user, + loggedInClient, school, externalTool, }; }; it('should throw notFoundException', async () => { - const { user, externalTool } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/tools/${externalTool.id}/configuration`); + const response: Response = await loggedInClient.get( + `school-external-tools/${new ObjectId().toHexString()}/configuration-template` + ); expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); }); }); - describe('GET tools/:toolId/:context/:id/configuration', () => { + describe('GET tools/context-external-tools/:contextExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { const school: School = schoolFactory.buildWithId(); @@ -415,25 +482,43 @@ describe('ToolSchoolController (API)', () => { const course: Course = courseFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [] }); + const account: Account = accountFactory.buildWithId({ userId: user.id }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - await em.persistAndFlush([user, school, externalTool, course]); - em.clear(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - return { + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + }); + + await em.persistAndFlush([ user, + account, + school, externalTool, + schoolExternalTool, + contextExternalTool, course, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(account); + + return { + loggedInClient, + contextExternalTool, }; }; it('should return a forbidden status', async () => { - const { user, externalTool, course } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, contextExternalTool } = await setup(); - const response: Response = await request(app.getHttpServer()).get( - `/tools/${externalTool.id}/course/${course.id}/configuration` + const response: Response = await loggedInClient.get( + `context-external-tools/${contextExternalTool.id}/configuration-template` ); expect(response.status).toEqual(HttpStatus.FORBIDDEN); @@ -442,113 +527,133 @@ describe('ToolSchoolController (API)', () => { describe('when tool is not hidden', () => { const setup = async () => { - const teacherRole: Role = roleFactory.buildWithId({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], + const school: School = schoolFactory.buildWithId(); + + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); + + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [globalParameter, schoolParameter, contextParameter], }); - const school: School = schoolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - const user: User = userFactory.buildWithId({ school, roles: [teacherRole] }); - - const course: Course = courseFactory.buildWithId({ school, teachers: [user] }); - - const customParameterResponse: CustomParameterResponse[] = [ - { - name: 'name', - displayName: 'User Friendly Name', - description: 'This is a mock parameter.', - defaultValue: 'default', - location: CustomParameterLocationParams.PATH, - scope: CustomParameterScopeTypeParams.CONTEXT, - type: CustomParameterTypeParams.STRING, - regex: 'regex', - regexComment: 'mockComment', - isOptional: false, - }, - ]; - - const customParameters: CustomParameter[] = [ - { - name: 'name', - displayName: 'User Friendly Name', - description: 'This is a mock parameter.', - default: 'default', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.CONTEXT, - type: CustomParameterType.STRING, - regex: 'regex', - regexComment: 'mockComment', - isOptional: false, - }, - ]; - - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - parameters: customParameters, + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, }); - await em.persistAndFlush([user, school, course, teacherRole, externalTool]); + await em.persistAndFlush([ + teacherUser, + teacherAccount, + school, + externalTool, + schoolExternalTool, + contextExternalTool, + course, + ]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - user, - school, - course, + loggedInClient, externalTool, - customParameterResponse, + contextParameter, + schoolExternalTool, + contextExternalTool, }; }; - it('should return a tool', async () => { - const { user, externalTool, customParameterResponse, course } = await setup(); - currentUser = mapUserToCurrentUser(user); + it('should return a tool with parameter with scope context', async () => { + const { loggedInClient, externalTool, schoolExternalTool, contextParameter, contextExternalTool } = + await setup(); - const response: Response = await request(app.getHttpServer()).get( - `/tools/${externalTool.id}/course/${course.id}/configuration` + const response: Response = await loggedInClient.get( + `context-external-tools/${contextExternalTool.id}/configuration-template` ); - expect(response.body).toEqual({ - id: externalTool.id, + expect(response.body).toEqual({ + externalToolId: externalTool.id, + schoolExternalToolId: schoolExternalTool.id, name: externalTool.name, logoUrl: externalTool.logoUrl, version: externalTool.version, - parameters: customParameterResponse, + parameters: [ + { + name: contextParameter.name, + displayName: contextParameter.displayName, + isOptional: contextParameter.isOptional, + defaultValue: contextParameter.default, + description: contextParameter.description, + regex: contextParameter.regex, + regexComment: contextParameter.regexComment, + type: CustomParameterTypeParams.STRING, + scope: CustomParameterScopeTypeParams.CONTEXT, + location: CustomParameterLocationParams.BODY, + }, + ], }); }); }); describe('when tool is hidden', () => { const setup = async () => { - const teacherRole: Role = roleFactory.buildWithId({ - name: RoleName.TEACHER, - permissions: [Permission.CONTEXT_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ school, roles: [teacherRole] }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); - const course: Course = courseFactory.buildWithId({ school, teachers: [user] }); + const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ isHidden: true }); + + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalTool, + }); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: true }); + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); - await em.persistAndFlush([user, school, teacherRole, externalTool, course]); + await em.persistAndFlush([ + teacherUser, + school, + teacherAccount, + externalTool, + schoolExternalTool, + contextExternalTool, + course, + ]); em.clear(); + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + return { - user, + loggedInClient, school, externalTool, - course, + contextExternalTool, }; }; it('should throw notFoundException', async () => { - const { user, externalTool, course } = await setup(); - currentUser = mapUserToCurrentUser(user); + const { loggedInClient, contextExternalTool } = await setup(); - const response: Response = await request(app.getHttpServer()).get( - `/tools/${externalTool.id}/course/${course.id}/configuration` + const response: Response = await loggedInClient.get( + `context-external-tools/${contextExternalTool.id}/configuration-template` ); expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index 65df7e3defa..4f844fed7b0 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -1,25 +1,20 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { - ContextExternalTool, - ContextExternalToolType, - Course, - ExternalTool, - Permission, - School, - SchoolExternalTool, -} from '@shared/domain'; +import { Course, Permission, School } from '@shared/domain'; import { cleanupCollections, - contextExternalToolFactory, + contextExternalToolEntityFactory, courseFactory, + externalToolEntityFactory, externalToolFactory, - schoolExternalToolFactory, + schoolExternalToolEntityFactory, schoolFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { Response } from 'supertest'; import { Loaded } from '@mikro-orm/core'; import { ServerTestModule } from '@src/modules/server'; @@ -29,7 +24,7 @@ import { CustomParameterTypeParams, ToolConfigType, ToolContextType, -} from '../../../common/interface'; +} from '../../../common/enum'; import { ExternalToolCreateParams, ExternalToolResponse, @@ -38,12 +33,16 @@ import { ToolReferenceListResponse, } from '../dto'; import { ContextExternalToolContextParams } from '../../../context-external-tool/controller/dto'; +import { ExternalToolEntity } from '../../entity'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; describe('ToolController (API)', () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; + let axiosMock: MockAdapter; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ @@ -51,11 +50,12 @@ describe('ToolController (API)', () => { }).compile(); app = moduleRef.createNestApplication(); + axiosMock = new MockAdapter(axios); await app.init(); em = app.get(EntityManager); - testApiClient = new TestApiClient(app, 'tools'); + testApiClient = new TestApiClient(app, 'tools/external-tools'); }); afterAll(async () => { @@ -66,7 +66,7 @@ describe('ToolController (API)', () => { await cleanupCollections(em); }); - describe('[POST] tools', () => { + describe('[POST] tools/external-tools', () => { const postParams: ExternalToolCreateParams = { name: 'Tool 1', parameters: [ @@ -101,6 +101,10 @@ describe('ToolController (API)', () => { await em.persistAndFlush([adminAccount, adminUser]); em.clear(); + const base64Logo: string = externalToolFactory.withBase64Logo().build().logo as string; + const logoBuffer: Buffer = Buffer.from(base64Logo, 'base64'); + axiosMock.onGet(params.logoUrl).reply(HttpStatus.OK, logoBuffer); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); return { loggedInClient, params }; @@ -113,7 +117,7 @@ describe('ToolController (API)', () => { const body: ExternalToolResponse = response.body as ExternalToolResponse; - const loaded: Loaded = await em.findOneOrFail(ExternalTool, { id: body.id }); + const loaded: Loaded = await em.findOneOrFail(ExternalToolEntity, { id: body.id }); expect(loaded).toBeDefined(); }); @@ -216,14 +220,14 @@ describe('ToolController (API)', () => { }); }); - describe('[GET] tools', () => { + describe('[GET] tools/external-tools', () => { describe('when requesting tools', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -256,10 +260,10 @@ describe('ToolController (API)', () => { describe('when permission is missing', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin(); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -277,14 +281,14 @@ describe('ToolController (API)', () => { }); }); - describe('[GET] tools/:toolId', () => { - describe('when toolId is given', () => { + describe('[GET] tools/external-tools/:externalToolId', () => { + describe('when externalToolId is given', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin(undefined, [Permission.TOOL_ADMIN]); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -355,7 +359,7 @@ describe('ToolController (API)', () => { }); }); - describe('[POST] tools/:toolId', () => { + describe('[POST] tools/external-tools/:externalToolId', () => { const postParams: ExternalToolCreateParams = { name: 'Tool 1', parameters: [ @@ -386,10 +390,16 @@ describe('ToolController (API)', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); const params = { ...postParams, id: toolId }; - const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory + .withBase64Logo() + .buildWithId({ version: 1 }, toolId); + + const base64Logo: string = externalToolEntity.logoBase64 as string; + const logoBuffer: Buffer = Buffer.from(base64Logo, 'base64'); + axiosMock.onGet(params.logoUrl).reply(HttpStatus.OK, logoBuffer); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -403,7 +413,7 @@ describe('ToolController (API)', () => { const response: Response = await loggedInClient.post(`${toolId}`, params).expect(HttpStatus.CREATED); const body: ExternalToolResponse = response.body as ExternalToolResponse; - const loaded: Loaded = await em.findOneOrFail(ExternalTool, { id: body.id }); + const loaded: Loaded = await em.findOneOrFail(ExternalToolEntity, { id: body.id }); expect(loaded).toBeDefined(); }); @@ -489,10 +499,10 @@ describe('ToolController (API)', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); const params = { ...postParams, id: toolId }; - const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1 }, toolId); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin(); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -510,14 +520,14 @@ describe('ToolController (API)', () => { }); }); - describe('[DELETE] tools/:toolId', () => { + describe('[DELETE] tools/external-tools/:externalToolId', () => { describe('when valid data is given', () => { const setup = async () => { const toolId: string = new ObjectId().toHexString(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); - await em.persistAndFlush([adminAccount, adminUser, externalTool]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); @@ -528,9 +538,9 @@ describe('ToolController (API)', () => { it('should delete a tool', async () => { const { loggedInClient, toolId } = await setup(); - await loggedInClient.delete(`${toolId}`).expect(HttpStatus.OK); + await loggedInClient.delete(`${toolId}`).expect(HttpStatus.NO_CONTENT); - expect(await em.findOne(ExternalTool, { id: toolId })).toBeNull(); + expect(await em.findOne(ExternalToolEntity, { id: toolId })).toBeNull(); }); }); @@ -587,10 +597,10 @@ describe('ToolController (API)', () => { }); }); - describe('[GET] tools/references/:contextType/:contextId', () => { + describe('[GET] tools/external-tools/:contextType/:contextId/references', () => { describe('when user is not authenticated', () => { it('should return unauthorized', async () => { - const response: Response = await testApiClient.get(`references/contextType/${new ObjectId().toHexString()}`); + const response: Response = await testApiClient.get(`contextType/${new ObjectId().toHexString()}/references`); expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); }); @@ -602,13 +612,13 @@ describe('ToolController (API)', () => { const school: School = schoolFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, - tool: externalTool, + tool: externalToolEntity, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, }); @@ -618,9 +628,9 @@ describe('ToolController (API)', () => { adminAccount, adminUser, course, - externalTool, - schoolExternalTool, - contextExternalTool, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, ]); em.clear(); @@ -637,7 +647,7 @@ describe('ToolController (API)', () => { it('should filter out the tool', async () => { const { loggedInClient, params } = await setup(); - const response: Response = await loggedInClient.get(`references/${params.contextType}/${params.contextId}`); + const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}/references`); expect(response.statusCode).toEqual(HttpStatus.OK); expect(response.body).toEqual({ data: [] }); @@ -651,18 +661,18 @@ describe('ToolController (API)', () => { Permission.CONTEXT_TOOL_USER, ]); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ logoUrl: undefined }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, - tool: externalTool, - toolVersion: externalTool.version, + tool: externalToolEntity, + toolVersion: externalToolEntity.version, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, displayName: 'This is a test tool', - toolVersion: schoolExternalTool.toolVersion, + toolVersion: schoolExternalToolEntity.toolVersion, }); await em.persistAndFlush([ @@ -670,9 +680,9 @@ describe('ToolController (API)', () => { adminAccount, adminUser, course, - externalTool, - schoolExternalTool, - contextExternalTool, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, ]); em.clear(); @@ -683,27 +693,62 @@ describe('ToolController (API)', () => { const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - return { loggedInClient, params, contextExternalTool, externalTool }; + return { loggedInClient, params, contextExternalToolEntity, externalToolEntity }; }; it('should return an ToolReferenceListResponse with data', async () => { - const { loggedInClient, params, contextExternalTool, externalTool } = await setup(); + const { loggedInClient, params, contextExternalToolEntity, externalToolEntity } = await setup(); - const response: Response = await loggedInClient.get(`references/${params.contextType}/${params.contextId}`); + const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}/references`); expect(response.statusCode).toEqual(HttpStatus.OK); expect(response.body).toEqual({ data: [ { - contextToolId: contextExternalTool.id, - displayName: contextExternalTool.displayName as string, + contextToolId: contextExternalToolEntity.id, + displayName: contextExternalToolEntity.displayName as string, status: ToolConfigurationStatusResponse.LATEST, - logoUrl: externalTool.logoUrl, - openInNewTab: externalTool.openNewTab, + logoUrl: externalToolEntity.logoUrl, + openInNewTab: externalToolEntity.openNewTab, }, ], }); }); }); }); + + describe('[GET] tools/external-tools/:externalToolId/logo', () => { + const setup = async () => { + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.withBase64Logo().buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, externalToolEntity }; + }; + + describe('when user is not authenticated', () => { + it('should return unauthorized', async () => { + const { externalToolEntity } = await setup(); + + const response: Response = await testApiClient.get(`${externalToolEntity.id}/logo`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user is authenticated', () => { + it('should return the logo', async () => { + const { loggedInClient, externalToolEntity } = await setup(); + + const response: Response = await loggedInClient.get(`${externalToolEntity.id}/logo`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toBeInstanceOf(Buffer); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/basic-tool-config.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/basic-tool-config.params.ts index 9dd6080cbf8..fe41f30e851 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/basic-tool-config.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/basic-tool-config.params.ts @@ -1,7 +1,7 @@ import { IsEnum, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; -import { ToolConfigType } from '../../../../../common/interface'; +import { ToolConfigType } from '../../../../../common/enum'; export class BasicToolConfigParams extends ExternalToolConfigCreateParams { @IsEnum(ToolConfigType) diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/external-tool-config.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/external-tool-config.params.ts index 3063a863a23..7e650f82ff2 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/external-tool-config.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/external-tool-config.params.ts @@ -1,4 +1,4 @@ -import { ToolConfigType } from '../../../../../common/interface'; +import { ToolConfigType } from '../../../../../common/enum'; export abstract class ExternalToolConfigCreateParams { abstract type: ToolConfigType; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts index 54922f0ac20..5e3d41f20f9 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/interface'; +import { IsEnum, IsLocale, IsOptional, IsString } from 'class-validator'; +import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; export class Lti11ToolConfigCreateParams extends ExternalToolConfigCreateParams { @@ -32,4 +32,8 @@ export class Lti11ToolConfigCreateParams extends ExternalToolConfigCreateParams @IsEnum(LtiPrivacyPermission) @ApiProperty() privacy_permission!: LtiPrivacyPermission; + + @IsLocale() + @ApiProperty() + launch_presentation_locale!: string; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts index 9379675ef6f..87de384fb2d 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/interface'; +import { IsEnum, IsLocale, IsOptional, IsString } from 'class-validator'; +import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; export class Lti11ToolConfigUpdateParams extends ExternalToolConfigCreateParams { @@ -33,4 +33,8 @@ export class Lti11ToolConfigUpdateParams extends ExternalToolConfigCreateParams @IsEnum(LtiPrivacyPermission) @ApiProperty() privacy_permission!: LtiPrivacyPermission; + + @IsLocale() + @ApiProperty() + launch_presentation_locale!: string; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-create.params.ts index ba49e568ee3..63485e8404c 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-create.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; -import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/interface'; +import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; export class Oauth2ToolConfigCreateParams extends ExternalToolConfigCreateParams { diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-update.params.ts index 456d1fc9bf1..a4fcd9c2cde 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/oauth2-tool-config-update.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; -import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/interface'; +import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; export class Oauth2ToolConfigUpdateParams extends ExternalToolConfigCreateParams { diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/context-external-tool-id.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/context-external-tool-id.params.ts new file mode 100644 index 00000000000..662383a69e7 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/context-external-tool-id.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; +import { IsMongoId } from 'class-validator'; + +export class ContextExternalToolIdParams { + @IsMongoId() + @ApiProperty() + contextExternalToolId!: EntityId; +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/context-ref.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/context-ref.params.ts new file mode 100644 index 00000000000..1360d205d7e --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/context-ref.params.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; +import { IsEnum, IsMongoId } from 'class-validator'; +import { ToolContextType } from '../../../../common/enum'; + +export class ContextRefParams { + @IsEnum(ToolContextType) + @ApiProperty({ type: ToolContextType }) + contextType!: ToolContextType; + + @IsMongoId() + @ApiProperty() + contextId!: EntityId; +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/context-type.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/context-type.params.ts deleted file mode 100644 index dd83135102f..00000000000 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/context-type.params.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum } from 'class-validator'; -import { ToolContextType } from '../../../../common/interface'; - -export class ContextTypeParams { - @IsEnum(ToolContextType) - @ApiProperty({ type: ToolContextType }) - context!: ToolContextType; -} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts index 0d282a0b758..c996183b5ba 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts @@ -4,7 +4,7 @@ import { CustomParameterLocationParams, CustomParameterScopeTypeParams, CustomParameterTypeParams, -} from '../../../../common/interface'; +} from '../../../../common/enum'; export class CustomParameterPostParams { @IsString() diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts index 2d18dbb6949..b094bbe7f04 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { ToolConfigType } from '../../../../common/interface'; +import { ToolConfigType } from '../../../../common/enum'; import { BasicToolConfigParams, ExternalToolConfigCreateParams, diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/tool-id.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-id.params.ts similarity index 72% rename from apps/server/src/modules/tool/external-tool/controller/dto/request/tool-id.params.ts rename to apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-id.params.ts index c2c4cdb7f9c..433032f5c3b 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/tool-id.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-id.params.ts @@ -1,8 +1,8 @@ import { IsMongoId } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -export class ToolIdParams { +export class ExternalToolIdParams { @IsMongoId() @ApiProperty({ nullable: false, required: true }) - toolId!: string; + externalToolId!: string; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts index 7bc3eb4750a..6d34f738e5c 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { ToolConfigType } from '../../../../common/interface'; +import { ToolConfigType } from '../../../../common/enum'; import { BasicToolConfigParams, ExternalToolConfigCreateParams, diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts index d013d7a2428..e366a9df2e9 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts @@ -1,9 +1,11 @@ export * from './config'; -export * from './id.params'; -export * from './tool-id.params'; -export * from './context-type.params'; +export * from './external-tool-id.params'; export * from './custom-parameter.params'; export * from './external-tool-sort.params'; export * from './external-tool-create.params'; export * from './external-tool-search.params'; export * from './external-tool-update.params'; +export * from './school-id.params'; +export * from './context-ref.params'; +export * from './school-external-tool-id.params'; +export * from './context-external-tool-id.params'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/school-external-tool-id.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/school-external-tool-id.params.ts new file mode 100644 index 00000000000..61ae44318ed --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/school-external-tool-id.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; +import { IsMongoId } from 'class-validator'; + +export class SchoolExternalToolIdParams { + @IsMongoId() + @ApiProperty() + schoolExternalToolId!: EntityId; +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/id.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/school-id.params.ts similarity index 76% rename from apps/server/src/modules/tool/external-tool/controller/dto/request/id.params.ts rename to apps/server/src/modules/tool/external-tool/controller/dto/request/school-id.params.ts index 701896e41e4..a2c55bc1ab7 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/id.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/school-id.params.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { EntityId } from '@shared/domain'; import { IsMongoId } from 'class-validator'; -export class IdParams { +export class SchoolIdParams { @IsMongoId() @ApiProperty() - id!: EntityId; + schoolId!: EntityId; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/basic-tool-config.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/basic-tool-config.response.ts index 426aadfeede..34070426778 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/basic-tool-config.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/basic-tool-config.response.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ToolConfigType } from '../../../../../common/interface'; +import { ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigResponse } from './external-tool-config.response'; export class BasicToolConfigResponse extends ExternalToolConfigResponse { diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/external-tool-config.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/external-tool-config.response.ts index dbc00618fe3..541da1fe6d0 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/external-tool-config.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/external-tool-config.response.ts @@ -1,4 +1,4 @@ -import { ToolConfigType } from '../../../../../common/interface'; +import { ToolConfigType } from '../../../../../common/enum'; export abstract class ExternalToolConfigResponse { abstract type: ToolConfigType; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts index 0e89ebd904f..a38a62542fe 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigResponse } from './external-tool-config.response'; -import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/interface'; export class Lti11ToolConfigResponse extends ExternalToolConfigResponse { @ApiProperty() @@ -21,6 +21,9 @@ export class Lti11ToolConfigResponse extends ExternalToolConfigResponse { @ApiProperty() privacy_permission: LtiPrivacyPermission; + @ApiProperty() + launch_presentation_locale: string; + constructor(props: Lti11ToolConfigResponse) { super(); this.type = ToolConfigType.LTI11; @@ -29,5 +32,6 @@ export class Lti11ToolConfigResponse extends ExternalToolConfigResponse { this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; + this.launch_presentation_locale = props.launch_presentation_locale; } } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/oauth2-tool-config.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/oauth2-tool-config.response.ts index d9dad6d9cfa..119c00d0f4c 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/oauth2-tool-config.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/oauth2-tool-config.response.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ExternalToolConfigResponse } from './external-tool-config.response'; -import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/interface'; +import { TokenEndpointAuthMethod, ToolConfigType } from '../../../../../common/enum'; export class Oauth2ToolConfigResponse extends ExternalToolConfigResponse { @ApiProperty() diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template-list.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template-list.response.ts new file mode 100644 index 00000000000..2126b29a6bd --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template-list.response.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContextExternalToolConfigurationTemplateResponse } from './context-external-tool-configuration-template.response'; + +export class ContextExternalToolConfigurationTemplateListResponse { + @ApiProperty({ type: [ContextExternalToolConfigurationTemplateResponse] }) + data: ContextExternalToolConfigurationTemplateResponse[]; + + constructor(data: ContextExternalToolConfigurationTemplateResponse[]) { + this.data = data; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts new file mode 100644 index 00000000000..7267751e5d5 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; +import { CustomParameterResponse } from './custom-parameter.response'; + +export class ContextExternalToolConfigurationTemplateResponse { + @ApiProperty() + externalToolId: EntityId; + + @ApiProperty() + schoolExternalToolId: EntityId; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + logoUrl?: string; + + @ApiProperty({ type: [CustomParameterResponse] }) + parameters: CustomParameterResponse[]; + + @ApiProperty() + version: number; + + constructor(configuration: ContextExternalToolConfigurationTemplateResponse) { + this.externalToolId = configuration.externalToolId; + this.schoolExternalToolId = configuration.schoolExternalToolId; + this.name = configuration.name; + this.logoUrl = configuration.logoUrl; + this.parameters = configuration.parameters; + this.version = configuration.version; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts index cbdecad495d..058bbdb5d0d 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts @@ -3,7 +3,7 @@ import { CustomParameterLocationParams, CustomParameterScopeTypeParams, CustomParameterTypeParams, -} from '../../../../common/interface'; +} from '../../../../common/enum'; export class CustomParameterResponse { @ApiProperty() diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts index 52753c09877..fbae39a8b33 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts @@ -3,8 +3,9 @@ export * from './external-tool.response'; export * from './tool-reference.response'; export * from './custom-parameter.response'; export * from './tool-reference-list.response'; -export * from './tool-configuration-list.response'; -export * from './tool-configuration-entry.response'; export * from './external-tool-search-list.response'; export * from './tool-configuration-status.response'; -export * from './external-tool-configuration-template.response'; +export * from './context-external-tool-configuration-template.response'; +export * from './context-external-tool-configuration-template-list.response'; +export * from './school-external-tool-configuration-template.response'; +export * from './school-external-tool-configuration-template-list.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template-list.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template-list.response.ts new file mode 100644 index 00000000000..8d57af4aa68 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template-list.response.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SchoolExternalToolConfigurationTemplateResponse } from './school-external-tool-configuration-template.response'; + +export class SchoolExternalToolConfigurationTemplateListResponse { + @ApiProperty({ type: [SchoolExternalToolConfigurationTemplateResponse] }) + data: SchoolExternalToolConfigurationTemplateResponse[]; + + constructor(data: SchoolExternalToolConfigurationTemplateResponse[]) { + this.data = data; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-configuration-template.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts similarity index 71% rename from apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-configuration-template.response.ts rename to apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts index db4e6bf13e9..7de80cca1ae 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-configuration-template.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts @@ -1,10 +1,10 @@ -import { EntityId } from '@shared/domain'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; import { CustomParameterResponse } from './custom-parameter.response'; -export class ExternalToolConfigurationTemplateResponse { +export class SchoolExternalToolConfigurationTemplateResponse { @ApiProperty() - id: EntityId; + externalToolId: EntityId; @ApiProperty() name: string; @@ -18,8 +18,8 @@ export class ExternalToolConfigurationTemplateResponse { @ApiProperty() version: number; - constructor(configuration: ExternalToolConfigurationTemplateResponse) { - this.id = configuration.id; + constructor(configuration: SchoolExternalToolConfigurationTemplateResponse) { + this.externalToolId = configuration.externalToolId; this.name = configuration.name; this.logoUrl = configuration.logoUrl; this.parameters = configuration.parameters; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-entry.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-entry.response.ts deleted file mode 100644 index 7416dc6f891..00000000000 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-entry.response.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EntityId } from '@shared/domain'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class ToolConfigurationEntryResponse { - @ApiProperty() - id: EntityId; - - @ApiProperty() - name: string; - - @ApiPropertyOptional() - logoUrl?: string; - - constructor(response: ToolConfigurationEntryResponse) { - this.id = response.id; - this.name = response.name; - this.logoUrl = response.logoUrl; - } -} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-list.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-list.response.ts deleted file mode 100644 index dd4dfef75c1..00000000000 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-list.response.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { ToolConfigurationEntryResponse } from './tool-configuration-entry.response'; - -export class ToolConfigurationListResponse { - @ApiProperty({ type: [ToolConfigurationEntryResponse] }) - data: ToolConfigurationEntryResponse[]; - - constructor(data: ToolConfigurationEntryResponse[]) { - this.data = data; - } -} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts index a013d6ecd50..24844d8bd2a 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts @@ -5,7 +5,11 @@ export class ToolReferenceResponse { @ApiProperty({ nullable: false, required: true, description: 'The id of the tool in the context' }) contextToolId: string; - @ApiPropertyOptional({ nullable: false, required: false, description: 'The url of the logo of the tool' }) + @ApiPropertyOptional({ + nullable: false, + required: false, + description: 'The url of the logo which is stored in the db', + }) logoUrl?: string; @ApiProperty({ nullable: false, required: true, description: 'The display name of the tool' }) diff --git a/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts index bf805d52eb9..ecdcc32f2a2 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts @@ -1,6 +1,5 @@ import { Controller, Get, Param } from '@nestjs/common'; import { - ApiBearerAuth, ApiForbiddenResponse, ApiFoundResponse, ApiOkResponse, @@ -8,113 +7,118 @@ import { ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; -import { ExternalToolDO } from '@shared/domain'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; -import { ExternalToolConfigurationUc } from '../uc'; +import { ExternalTool } from '../domain'; +import { ToolConfigurationMapper } from '../mapper/tool-configuration.mapper'; +import { ContextExternalToolTemplateInfo, ExternalToolConfigurationUc } from '../uc'; import { - ContextTypeParams, - ExternalToolConfigurationTemplateResponse, - IdParams, - ToolConfigurationListResponse, - ToolIdParams, + ContextExternalToolConfigurationTemplateListResponse, + ContextExternalToolConfigurationTemplateResponse, + ContextExternalToolIdParams, + ContextRefParams, + SchoolExternalToolConfigurationTemplateListResponse, + SchoolExternalToolConfigurationTemplateResponse, + SchoolExternalToolIdParams, + SchoolIdParams, } from './dto'; -import { ExternalToolResponseMapper } from '../mapper'; -import { SchoolToolConfigurationListResponse } from '../../school-external-tool/controller/dto'; -import { SchoolExternalToolResponseMapper } from '../../school-external-tool/mapper'; @ApiTags('Tool') @Authenticate('jwt') @Controller('tools') export class ToolConfigurationController { - constructor( - private readonly externalToolConfigurationUc: ExternalToolConfigurationUc, - private readonly externalToolResponseMapper: ExternalToolResponseMapper - ) {} + constructor(private readonly externalToolConfigurationUc: ExternalToolConfigurationUc) {} - @Get('available/school/:id') + @Get('school/:schoolId/available-tools') @ApiForbiddenResponse() + @ApiOperation({ summary: 'Lists all available tools that can be added for a given school' }) + @ApiOkResponse({ + description: 'List of available tools for a school', + type: SchoolExternalToolConfigurationTemplateListResponse, + }) public async getAvailableToolsForSchool( @CurrentUser() currentUser: ICurrentUser, - @Param() idParams: IdParams - ): Promise { - const availableTools: ExternalToolDO[] = await this.externalToolConfigurationUc.getAvailableToolsForSchool( + @Param() params: SchoolIdParams + ): Promise { + const availableTools: ExternalTool[] = await this.externalToolConfigurationUc.getAvailableToolsForSchool( currentUser.userId, - idParams.id + params.schoolId ); - const mapped: ToolConfigurationListResponse = - this.externalToolResponseMapper.mapExternalToolDOsToToolConfigurationListResponse(availableTools); + const mapped: SchoolExternalToolConfigurationTemplateListResponse = + ToolConfigurationMapper.mapToSchoolExternalToolConfigurationTemplateListResponse(availableTools); return mapped; } - @Get('available/:context/:id') - @ApiBearerAuth() + @Get(':contextType/:contextId/available-tools') @ApiForbiddenResponse() @ApiOperation({ summary: 'Lists all available tools that can be added for a given context' }) @ApiOkResponse({ description: 'List of available tools for a context', - type: SchoolToolConfigurationListResponse, + type: ContextExternalToolConfigurationTemplateListResponse, }) public async getAvailableToolsForContext( @CurrentUser() currentUser: ICurrentUser, - @Param() contextParams: ContextTypeParams, - @Param() idParams: IdParams - ): Promise { - const availableToolsForContext = await this.externalToolConfigurationUc.getAvailableToolsForContext( - currentUser.userId, - currentUser.schoolId, - idParams.id, - contextParams.context - ); - - const mapped: SchoolToolConfigurationListResponse = - SchoolExternalToolResponseMapper.mapExternalToolDOsToSchoolToolConfigurationListResponse( - availableToolsForContext + @Param() params: ContextRefParams + ): Promise { + const availableTools: ContextExternalToolTemplateInfo[] = + await this.externalToolConfigurationUc.getAvailableToolsForContext( + currentUser.userId, + currentUser.schoolId, + params.contextId, + params.contextType ); + const mapped: ContextExternalToolConfigurationTemplateListResponse = + ToolConfigurationMapper.mapToContextExternalToolConfigurationTemplateListResponse(availableTools); + return mapped; } - @Get(':toolId/configuration') + @Get('school-external-tools/:schoolExternalToolId/configuration-template') @ApiUnauthorizedResponse() - @ApiFoundResponse({ description: 'Configuration has been found.', type: ExternalToolConfigurationTemplateResponse }) - public async getExternalToolForScope( + @ApiForbiddenResponse() + @ApiOperation({ summary: 'Get the latest configuration template for a School External Tool' }) + @ApiFoundResponse({ + description: 'Configuration template for a School External Tool', + type: SchoolExternalToolConfigurationTemplateResponse, + }) + public async getConfigurationTemplateForSchool( @CurrentUser() currentUser: ICurrentUser, - @Param() params: ToolIdParams - ): Promise { - const externalToolDO: ExternalToolDO = await this.externalToolConfigurationUc.getExternalToolForSchool( + @Param() params: SchoolExternalToolIdParams + ): Promise { + const tool: ExternalTool = await this.externalToolConfigurationUc.getTemplateForSchoolExternalTool( currentUser.userId, - params.toolId, - currentUser.schoolId + params.schoolExternalToolId ); - const mapped: ExternalToolConfigurationTemplateResponse = - this.externalToolResponseMapper.mapToConfigurationTemplateResponse(externalToolDO); + const mapped: SchoolExternalToolConfigurationTemplateResponse = + ToolConfigurationMapper.mapToSchoolExternalToolConfigurationTemplateResponse(tool); return mapped; } - @Get(':toolId/:context/:id/configuration') + @Get('context-external-tools/:contextExternalToolId/configuration-template') @ApiUnauthorizedResponse() @ApiForbiddenResponse() - @ApiFoundResponse({ description: 'Configuration has been found.', type: ExternalToolConfigurationTemplateResponse }) - public async getExternalToolForContext( + @ApiOperation({ summary: 'Get the latest configuration template for a Context External Tool' }) + @ApiFoundResponse({ + description: 'Configuration template for a Context External Tool', + type: ContextExternalToolConfigurationTemplateResponse, + }) + public async getConfigurationTemplateForContext( @CurrentUser() currentUser: ICurrentUser, - @Param() params: ToolIdParams, - @Param() contextParams: ContextTypeParams, - @Param() idParams: IdParams - ): Promise { - const externalToolDO: ExternalToolDO = await this.externalToolConfigurationUc.getExternalToolForContext( - currentUser.userId, - params.toolId, - idParams.id, - contextParams.context - ); + @Param() params: ContextExternalToolIdParams + ): Promise { + const tool: ContextExternalToolTemplateInfo = + await this.externalToolConfigurationUc.getTemplateForContextExternalTool( + currentUser.userId, + params.contextExternalToolId + ); - const mapped: ExternalToolConfigurationTemplateResponse = - this.externalToolResponseMapper.mapToConfigurationTemplateResponse(externalToolDO); + const mapped: ContextExternalToolConfigurationTemplateResponse = + ToolConfigurationMapper.mapToContextExternalToolConfigurationTemplateResponse(tool); return mapped; } diff --git a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts index 89887e3d14a..3e6ac38fedc 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common'; import { ApiCreatedResponse, ApiForbiddenResponse, @@ -12,13 +12,17 @@ import { } from '@nestjs/swagger'; import { ValidationError } from '@shared/common'; import { PaginationParams } from '@shared/controller'; -import { ExternalToolDO, IFindOptions, Page, ToolReference } from '@shared/domain'; +import { IFindOptions, Page } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { Response } from 'express'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate, ToolReferenceUc } from '../uc'; +import { ContextExternalToolContextParams } from '../../context-external-tool/controller/dto'; +import { ExternalTool, ToolReference } from '../domain'; +import { ExternalToolLogo } from '../domain/external-tool-logo'; import { ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper'; +import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate, ToolReferenceUc } from '../uc'; import { ExternalToolCreateParams, ExternalToolResponse, @@ -26,22 +30,22 @@ import { ExternalToolSearchParams, ExternalToolUpdateParams, SortExternalToolParams, - ToolIdParams, + ExternalToolIdParams, ToolReferenceListResponse, ToolReferenceResponse, } from './dto'; -import { ContextExternalToolContextParams } from '../../context-external-tool/controller/dto'; +import { ExternalToolLogoService } from '../service'; @ApiTags('Tool') @Authenticate('jwt') -@Controller('tools') +@Controller('tools/external-tools') export class ToolController { constructor( private readonly externalToolUc: ExternalToolUc, private readonly externalToolDOMapper: ExternalToolRequestMapper, - private readonly externalResponseMapper: ExternalToolResponseMapper, private readonly toolReferenceUc: ToolReferenceUc, - private readonly logger: LegacyLogger + private readonly logger: LegacyLogger, + private readonly externalToolLogoService: ExternalToolLogoService ) {} @Post() @@ -50,15 +54,16 @@ export class ToolController { @ApiUnprocessableEntityResponse() @ApiUnauthorizedResponse() @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiOperation({ summary: 'Creates an ExternalTool' }) async createExternalTool( @CurrentUser() currentUser: ICurrentUser, @Body() externalToolParams: ExternalToolCreateParams ): Promise { - const externalToolDO: ExternalToolCreate = this.externalToolDOMapper.mapCreateRequest(externalToolParams); + const externalTool: ExternalToolCreate = this.externalToolDOMapper.mapCreateRequest(externalToolParams); - const created: ExternalToolDO = await this.externalToolUc.createExternalTool(currentUser.userId, externalToolDO); + const created: ExternalTool = await this.externalToolUc.createExternalTool(currentUser.userId, externalTool); - const mapped: ExternalToolResponse = this.externalResponseMapper.mapToExternalToolResponse(created); + const mapped: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(created); this.logger.debug(`ExternalTool with id ${mapped.id} was created by user with id ${currentUser.userId}`); @@ -69,21 +74,22 @@ export class ToolController { @ApiFoundResponse({ description: 'Tools has been found.', type: ExternalToolSearchListResponse }) @ApiUnauthorizedResponse() @ApiForbiddenResponse() + @ApiOperation({ summary: 'Returns a list of ExternalTools' }) async findExternalTool( @CurrentUser() currentUser: ICurrentUser, @Query() filterQuery: ExternalToolSearchParams, @Query() pagination: PaginationParams, @Query() sortingQuery: SortExternalToolParams ): Promise { - const options: IFindOptions = { pagination }; + const options: IFindOptions = { pagination }; options.order = this.externalToolDOMapper.mapSortingQueryToDomain(sortingQuery); const query: ExternalToolSearchQuery = this.externalToolDOMapper.mapExternalToolFilterQueryToExternalToolSearchQuery(filterQuery); - const tools: Page = await this.externalToolUc.findExternalTool(currentUser.userId, query, options); + const tools: Page = await this.externalToolUc.findExternalTool(currentUser.userId, query, options); const dtoList: ExternalToolResponse[] = tools.data.map( - (tool: ExternalToolDO): ExternalToolResponse => this.externalResponseMapper.mapToExternalToolResponse(tool) + (tool: ExternalTool): ExternalToolResponse => ExternalToolResponseMapper.mapToExternalToolResponse(tool) ); const response: ExternalToolSearchListResponse = new ExternalToolSearchListResponse( dtoList, @@ -95,51 +101,63 @@ export class ToolController { return response; } - @Get(':toolId') + @Get(':externalToolId') + @ApiOperation({ summary: 'Returns an ExternalTool for the given id' }) async getExternalTool( @CurrentUser() currentUser: ICurrentUser, - @Param() params: ToolIdParams + @Param() params: ExternalToolIdParams ): Promise { - const externalToolDO: ExternalToolDO = await this.externalToolUc.getExternalTool(currentUser.userId, params.toolId); - const mapped: ExternalToolResponse = this.externalResponseMapper.mapToExternalToolResponse(externalToolDO); + const externalTool: ExternalTool = await this.externalToolUc.getExternalTool( + currentUser.userId, + params.externalToolId + ); + const mapped: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(externalTool); return mapped; } - @Post('/:toolId') + @Post('/:externalToolId') @ApiOkResponse({ description: 'The Tool has been successfully updated.', type: ExternalToolResponse }) @ApiForbiddenResponse() @ApiUnauthorizedResponse() @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiOperation({ summary: 'Updates an ExternalTool' }) async updateExternalTool( @CurrentUser() currentUser: ICurrentUser, - @Param() params: ToolIdParams, + @Param() params: ExternalToolIdParams, @Body() externalToolParams: ExternalToolUpdateParams ): Promise { const externalTool: ExternalToolUpdate = this.externalToolDOMapper.mapUpdateRequest(externalToolParams); - const updated: ExternalToolDO = await this.externalToolUc.updateExternalTool( + const updated: ExternalTool = await this.externalToolUc.updateExternalTool( currentUser.userId, - params.toolId, + params.externalToolId, externalTool ); - const mapped: ExternalToolResponse = this.externalResponseMapper.mapToExternalToolResponse(updated); + const mapped: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(updated); this.logger.debug(`ExternalTool with id ${mapped.id} was updated by user with id ${currentUser.userId}`); return mapped; } - @Delete(':toolId') + @Delete(':externalToolId') @ApiForbiddenResponse({ description: 'User is not allowed to access this resource.' }) @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) - async deleteExternalTool(@CurrentUser() currentUser: ICurrentUser, @Param() params: ToolIdParams): Promise { - const promise: Promise = this.externalToolUc.deleteExternalTool(currentUser.userId, params.toolId); - this.logger.debug(`ExternalTool with id ${params.toolId} was deleted by user with id ${currentUser.userId}`); + @ApiOperation({ summary: 'Deletes an ExternalTool' }) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteExternalTool( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ExternalToolIdParams + ): Promise { + const promise: Promise = this.externalToolUc.deleteExternalTool(currentUser.userId, params.externalToolId); + this.logger.debug( + `ExternalTool with id ${params.externalToolId} was deleted by user with id ${currentUser.userId}` + ); return promise; } - @Get('/references/:contextType/:contextId') - @ApiOperation({ summary: 'Get Tool References' }) + @Get('/:contextType/:contextId/references') + @ApiOperation({ summary: 'Get ExternalTool References for a given context' }) @ApiOkResponse({ description: 'The Tool References has been successfully fetched.', type: ToolReferenceListResponse, @@ -153,13 +171,29 @@ export class ToolController { const toolReferences: ToolReference[] = await this.toolReferenceUc.getToolReferences( currentUser.userId, params.contextType, - params.contextId + params.contextId, + '/v3/tools/external-tools/{id}/logo' ); const toolReferenceResponses: ToolReferenceResponse[] = - this.externalResponseMapper.mapToToolReferenceResponses(toolReferences); + ExternalToolResponseMapper.mapToToolReferenceResponses(toolReferences); const toolReferenceListResponse = new ToolReferenceListResponse(toolReferenceResponses); return toolReferenceListResponse; } + + @Get('/:externalToolId/logo') + @ApiOperation({ summary: 'Gets the logo of an external tool.' }) + @ApiOkResponse({ + description: 'Logo of external tool fetched successfully.', + }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + async getExternalToolLogo(@Param() params: ExternalToolIdParams, @Res() res: Response): Promise { + const externalToolLogo: ExternalToolLogo = await this.externalToolLogoService.getExternalToolBinaryLogo( + params.externalToolId + ); + res.setHeader('Content-Type', externalToolLogo.contentType); + res.setHeader('Cache-Control', 'must-revalidate'); + res.send(externalToolLogo.logo); + } } diff --git a/apps/server/src/modules/tool/external-tool/domain/config/basic-tool-config.do.ts b/apps/server/src/modules/tool/external-tool/domain/config/basic-tool-config.do.ts new file mode 100644 index 00000000000..0e34c9abc6b --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/config/basic-tool-config.do.ts @@ -0,0 +1,11 @@ +import { ToolConfigType } from '../../../common/enum'; +import { ExternalToolConfig } from './external-tool-config.do'; + +export class BasicToolConfig extends ExternalToolConfig { + constructor(props: BasicToolConfig) { + super({ + type: ToolConfigType.BASIC, + baseUrl: props.baseUrl, + }); + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/config/external-tool-config.do.ts b/apps/server/src/modules/tool/external-tool/domain/config/external-tool-config.do.ts new file mode 100644 index 00000000000..59a9c4d0943 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/config/external-tool-config.do.ts @@ -0,0 +1,12 @@ +import { ToolConfigType } from '../../../common/enum'; + +export abstract class ExternalToolConfig { + type: ToolConfigType; + + baseUrl: string; + + constructor(props: ExternalToolConfig) { + this.type = props.type; + this.baseUrl = props.baseUrl; + } +} diff --git a/apps/server/src/shared/domain/domainobject/tool/config/index.ts b/apps/server/src/modules/tool/external-tool/domain/config/index.ts similarity index 80% rename from apps/server/src/shared/domain/domainobject/tool/config/index.ts rename to apps/server/src/modules/tool/external-tool/domain/config/index.ts index d6412091c9f..39b99cb01be 100644 --- a/apps/server/src/shared/domain/domainobject/tool/config/index.ts +++ b/apps/server/src/modules/tool/external-tool/domain/config/index.ts @@ -2,4 +2,3 @@ export * from './basic-tool-config.do'; export * from './lti11-tool-config.do'; export * from './oauth2-tool-config.do'; export * from './external-tool-config.do'; -export * from './tool-config-type.enum'; diff --git a/apps/server/src/shared/domain/domainobject/tool/config/lti11-tool-config.do.ts b/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts similarity index 54% rename from apps/server/src/shared/domain/domainobject/tool/config/lti11-tool-config.do.ts rename to apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts index b3e08962db3..faf9ae41f66 100644 --- a/apps/server/src/shared/domain/domainobject/tool/config/lti11-tool-config.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts @@ -1,8 +1,7 @@ -import { LtiMessageType, LtiPrivacyPermission } from '@shared/domain/entity'; -import { ExternalToolConfigDO } from './external-tool-config.do'; -import { ToolConfigType } from './tool-config-type.enum'; +import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../common/enum'; +import { ExternalToolConfig } from './external-tool-config.do'; -export class Lti11ToolConfigDO extends ExternalToolConfigDO { +export class Lti11ToolConfig extends ExternalToolConfig { key: string; secret: string; @@ -13,7 +12,9 @@ export class Lti11ToolConfigDO extends ExternalToolConfigDO { privacy_permission: LtiPrivacyPermission; - constructor(props: Lti11ToolConfigDO) { + launch_presentation_locale: string; + + constructor(props: Lti11ToolConfig) { super({ type: ToolConfigType.LTI11, baseUrl: props.baseUrl, @@ -23,5 +24,6 @@ export class Lti11ToolConfigDO extends ExternalToolConfigDO { this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; + this.launch_presentation_locale = props.launch_presentation_locale; } } diff --git a/apps/server/src/shared/domain/domainobject/tool/config/oauth2-tool-config.do.ts b/apps/server/src/modules/tool/external-tool/domain/config/oauth2-tool-config.do.ts similarity index 65% rename from apps/server/src/shared/domain/domainobject/tool/config/oauth2-tool-config.do.ts rename to apps/server/src/modules/tool/external-tool/domain/config/oauth2-tool-config.do.ts index fec0cf43e25..3a232cca781 100644 --- a/apps/server/src/shared/domain/domainobject/tool/config/oauth2-tool-config.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/config/oauth2-tool-config.do.ts @@ -1,8 +1,7 @@ -import { TokenEndpointAuthMethod } from '@src/modules/tool/common/interface'; -import { ExternalToolConfigDO } from './external-tool-config.do'; -import { ToolConfigType } from './tool-config-type.enum'; +import { ExternalToolConfig } from './external-tool-config.do'; +import { TokenEndpointAuthMethod, ToolConfigType } from '../../../common/enum'; -export class Oauth2ToolConfigDO extends ExternalToolConfigDO { +export class Oauth2ToolConfig extends ExternalToolConfig { clientId: string; clientSecret?: string; @@ -17,7 +16,7 @@ export class Oauth2ToolConfigDO extends ExternalToolConfigDO { redirectUris?: string[]; - constructor(props: Oauth2ToolConfigDO) { + constructor(props: Oauth2ToolConfig) { super({ type: ToolConfigType.OAUTH2, baseUrl: props.baseUrl, diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool-logo.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool-logo.ts new file mode 100644 index 00000000000..84008e998b0 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool-logo.ts @@ -0,0 +1,10 @@ +export class ExternalToolLogo { + logo: Buffer; + + contentType: string; + + constructor(externalToolLogo: ExternalToolLogo) { + this.logo = externalToolLogo.logo; + this.contentType = externalToolLogo.contentType; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts new file mode 100644 index 00000000000..70f8be26658 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts @@ -0,0 +1,48 @@ +import { externalToolFactory } from '@shared/testing'; +import { ExternalTool } from './external-tool.do'; + +describe('ExternalTool', () => { + describe('isLti11Config', () => { + describe('when external tool with config.type Lti11 is given', () => { + it('should return true', () => { + const externalTool: ExternalTool = externalToolFactory.withLti11Config().buildWithId(); + + const func = () => ExternalTool.isLti11Config(externalTool.config); + + expect(func()).toBeTruthy(); + }); + }); + + describe('when external tool with config.type Lti11 is not given', () => { + it('should return false', () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + const func = () => ExternalTool.isLti11Config(externalTool.config); + + expect(func()).toBeFalsy(); + }); + }); + }); + + describe('isOauth2Config', () => { + describe('when external tool with config.type Oauth2 is given', () => { + it('should return true', () => { + const externalTool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); + + const func = () => ExternalTool.isOauth2Config(externalTool.config); + + expect(func()).toBeTruthy(); + }); + }); + + describe('when external tool with config.type Oauth2 is not given', () => { + it('should return false', () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + const func = () => ExternalTool.isOauth2Config(externalTool.config); + + expect(func()).toBeFalsy(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts new file mode 100644 index 00000000000..ba9f1d6e84d --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -0,0 +1,73 @@ +import { BaseDO } from '@shared/domain/domainobject/base.do'; +import { ToolVersion } from '../../common/interface'; +import { Oauth2ToolConfig, BasicToolConfig, Lti11ToolConfig, ExternalToolConfig } from './config'; +import { CustomParameter } from '../../common/domain'; +import { ToolConfigType } from '../../common/enum'; + +export interface ExternalToolProps { + id?: string; + + name: string; + + url?: string; + + logoUrl?: string; + + logo?: string; + + config: BasicToolConfig | Lti11ToolConfig | Oauth2ToolConfig; + + parameters?: CustomParameter[]; + + isHidden: boolean; + + openNewTab: boolean; + + version: number; +} + +export class ExternalTool extends BaseDO implements ToolVersion { + name: string; + + url?: string; + + logoUrl?: string; + + logo?: string; + + config: BasicToolConfig | Lti11ToolConfig | Oauth2ToolConfig; + + parameters?: CustomParameter[]; + + isHidden: boolean; + + openNewTab: boolean; + + version: number; + + constructor(props: ExternalToolProps) { + super(props.id); + + this.name = props.name; + this.url = props.url; + this.logoUrl = props.logoUrl; + this.logo = props.logo; + this.config = props.config; + this.parameters = props.parameters; + this.isHidden = props.isHidden; + this.openNewTab = props.openNewTab; + this.version = props.version; + } + + getVersion(): number { + return this.version; + } + + static isOauth2Config(config: ExternalToolConfig): config is Oauth2ToolConfig { + return ToolConfigType.OAUTH2 === config.type; + } + + static isLti11Config(config: ExternalToolConfig): config is Lti11ToolConfig { + return ToolConfigType.LTI11 === config.type; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/index.ts b/apps/server/src/modules/tool/external-tool/domain/index.ts new file mode 100644 index 00000000000..9eaf1f03cbb --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/index.ts @@ -0,0 +1,3 @@ +export * from './external-tool.do'; +export * from './config'; +export * from './tool-reference'; diff --git a/apps/server/src/shared/domain/domainobject/tool/tool-reference.ts b/apps/server/src/modules/tool/external-tool/domain/tool-reference.ts similarity index 85% rename from apps/server/src/shared/domain/domainobject/tool/tool-reference.ts rename to apps/server/src/modules/tool/external-tool/domain/tool-reference.ts index bf7b5cdc7bb..b3ab846d2b0 100644 --- a/apps/server/src/shared/domain/domainobject/tool/tool-reference.ts +++ b/apps/server/src/modules/tool/external-tool/domain/tool-reference.ts @@ -1,4 +1,4 @@ -import { ToolConfigurationStatus } from './tool-configuration-status'; +import { ToolConfigurationStatus } from '../../common/enum'; export class ToolReference { contextToolId: string; diff --git a/apps/server/src/modules/tool/external-tool/entity/config/basic-tool-config.entity.ts b/apps/server/src/modules/tool/external-tool/entity/config/basic-tool-config.entity.ts new file mode 100644 index 00000000000..0a24a23e99d --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/entity/config/basic-tool-config.entity.ts @@ -0,0 +1,12 @@ +import { Embeddable } from '@mikro-orm/core'; +import { ToolConfigType } from '../../../common/enum'; +import { ExternalToolConfigEntity } from './external-tool-config.entity'; + +@Embeddable({ discriminatorValue: ToolConfigType.BASIC }) +export class BasicToolConfigEntity extends ExternalToolConfigEntity { + constructor(props: BasicToolConfigEntity) { + super(props); + this.type = ToolConfigType.BASIC; + this.baseUrl = props.baseUrl; + } +} diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/external-tool-config.ts b/apps/server/src/modules/tool/external-tool/entity/config/external-tool-config.entity.ts similarity index 62% rename from apps/server/src/shared/domain/entity/tools/external-tool/config/external-tool-config.ts rename to apps/server/src/modules/tool/external-tool/entity/config/external-tool-config.entity.ts index 12341a3a506..eda137cc109 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/external-tool-config.ts +++ b/apps/server/src/modules/tool/external-tool/entity/config/external-tool-config.entity.ts @@ -1,15 +1,15 @@ import { Embeddable, Enum, Property } from '@mikro-orm/core'; -import { ToolConfigType } from './tool-config-type.enum'; +import { ToolConfigType } from '../../../common/enum'; @Embeddable({ abstract: true, discriminatorColumn: 'type' }) -export abstract class ExternalToolConfig { +export abstract class ExternalToolConfigEntity { @Enum() type: ToolConfigType; @Property() baseUrl: string; - constructor(props: ExternalToolConfig) { + constructor(props: ExternalToolConfigEntity) { this.type = props.type; this.baseUrl = props.baseUrl; } diff --git a/apps/server/src/modules/tool/external-tool/entity/config/index.ts b/apps/server/src/modules/tool/external-tool/entity/config/index.ts new file mode 100644 index 00000000000..dedd2c39f6d --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/entity/config/index.ts @@ -0,0 +1,4 @@ +export * from './basic-tool-config.entity'; +export * from './lti11-tool-config.entity'; +export * from './oauth2-tool-config.entity'; +export * from './external-tool-config.entity'; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/lti11-tool-config.ts b/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts similarity index 64% rename from apps/server/src/shared/domain/entity/tools/external-tool/config/lti11-tool-config.ts rename to apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts index 431f36ff43c..c5edfd09900 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/lti11-tool-config.ts +++ b/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts @@ -1,11 +1,10 @@ import { Embeddable, Enum, Property } from '@mikro-orm/core'; import { LtiPrivacyPermission } from '@shared/domain/entity/ltitool.entity'; -import { ExternalToolConfig } from './external-tool-config'; -import { LtiMessageType } from './lti-message-type.enum'; -import { ToolConfigType } from './tool-config-type.enum'; +import { LtiMessageType, ToolConfigType } from '../../../common/enum'; +import { ExternalToolConfigEntity } from './external-tool-config.entity'; @Embeddable({ discriminatorValue: ToolConfigType.LTI11 }) -export class Lti11ToolConfig extends ExternalToolConfig { +export class Lti11ToolConfigEntity extends ExternalToolConfigEntity { @Property() key: string; @@ -21,7 +20,10 @@ export class Lti11ToolConfig extends ExternalToolConfig { @Enum() privacy_permission: LtiPrivacyPermission; - constructor(props: Lti11ToolConfig) { + @Property() + launch_presentation_locale: string; + + constructor(props: Lti11ToolConfigEntity) { super(props); this.type = ToolConfigType.LTI11; this.key = props.key; @@ -29,5 +31,6 @@ export class Lti11ToolConfig extends ExternalToolConfig { this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; + this.launch_presentation_locale = props.launch_presentation_locale; } } diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/oauth2-tool-config.ts b/apps/server/src/modules/tool/external-tool/entity/config/oauth2-tool-config.entity.ts similarity index 56% rename from apps/server/src/shared/domain/entity/tools/external-tool/config/oauth2-tool-config.ts rename to apps/server/src/modules/tool/external-tool/entity/config/oauth2-tool-config.entity.ts index 012bd976e49..e26b0bc55c5 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/oauth2-tool-config.ts +++ b/apps/server/src/modules/tool/external-tool/entity/config/oauth2-tool-config.entity.ts @@ -1,16 +1,16 @@ import { Embeddable, Property } from '@mikro-orm/core'; -import { ExternalToolConfig } from './external-tool-config'; -import { ToolConfigType } from './tool-config-type.enum'; +import { ExternalToolConfigEntity } from './external-tool-config.entity'; +import { ToolConfigType } from '../../../common/enum'; @Embeddable({ discriminatorValue: ToolConfigType.OAUTH2 }) -export class Oauth2ToolConfig extends ExternalToolConfig { +export class Oauth2ToolConfigEntity extends ExternalToolConfigEntity { @Property() clientId: string; @Property() skipConsent: boolean; - constructor(props: Oauth2ToolConfig) { + constructor(props: Oauth2ToolConfigEntity) { super(props); this.type = ToolConfigType.OAUTH2; this.clientId = props.clientId; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter.ts b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts similarity index 75% rename from apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter.ts rename to apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts index 504cf0f0100..3f99cfe52b5 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/custom-parameter.ts +++ b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts @@ -1,10 +1,8 @@ import { Embeddable, Enum, Property } from '@mikro-orm/core'; -import { CustomParameterLocation } from './custom-parameter-location.enum'; -import { CustomParameterScope } from './custom-parameter-scope.enum'; -import { CustomParameterType } from './custom-parameter-type.enum'; +import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '../../../common/enum'; @Embeddable() -export class CustomParameter { +export class CustomParameterEntity { @Property() name: string; @@ -35,7 +33,7 @@ export class CustomParameter { @Property() isOptional: boolean; - constructor(props: CustomParameter) { + constructor(props: CustomParameterEntity) { this.name = props.name; this.displayName = props.displayName; this.description = props.description; diff --git a/apps/server/src/modules/tool/external-tool/entity/custom-parameter/index.ts b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/index.ts new file mode 100644 index 00000000000..c0dd53916f3 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/index.ts @@ -0,0 +1,4 @@ +export * from './custom-parameter.entity'; +export * from '../../../common/enum/custom-parameter-location.enum'; +export * from '../../../common/enum/custom-parameter-scope.enum'; +export * from '../../../common/enum/custom-parameter-type.enum'; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.spec.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts similarity index 56% rename from apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.spec.ts rename to apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts index 5155e3e1cec..933f007c440 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts @@ -1,36 +1,34 @@ +import { setupEntities } from '@shared/testing'; import { - BasicToolConfig, - CustomParameter, CustomParameterLocation, CustomParameterScope, CustomParameterType, - ExternalTool, - Lti11ToolConfig, LtiMessageType, LtiPrivacyPermission, - Oauth2ToolConfig, ToolConfigType, -} from '@shared/domain'; -import { setupEntities } from '@shared/testing'; +} from '../../common/enum'; +import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } from './config'; +import { CustomParameterEntity } from './custom-parameter'; +import { ExternalToolEntity } from './external-tool.entity'; -describe('ExternalTool Entity', () => { +describe('ExternalToolEntity', () => { beforeAll(async () => { await setupEntities(); }); describe('constructor', () => { const setup = () => { - const basicToolConfig: BasicToolConfig = new BasicToolConfig({ + const basicToolConfig: BasicToolConfigEntity = new BasicToolConfigEntity({ type: ToolConfigType.BASIC, baseUrl: 'mockBaseUrl', }); - const oauth2ToolConfig: Oauth2ToolConfig = new Oauth2ToolConfig({ + const oauth2ToolConfig: Oauth2ToolConfigEntity = new Oauth2ToolConfigEntity({ type: ToolConfigType.OAUTH2, baseUrl: 'mockBaseUrl', clientId: 'mockClientId', skipConsent: true, }); - const lti11ToolConfig: Lti11ToolConfig = new Lti11ToolConfig({ + const lti11ToolConfig: Lti11ToolConfigEntity = new Lti11ToolConfigEntity({ type: ToolConfigType.LTI11, baseUrl: 'mockBaseUrl', key: 'mockKey', @@ -38,8 +36,9 @@ describe('ExternalTool Entity', () => { resource_link_id: 'mockLink', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: 'de-DE', }); - const customParameter: CustomParameter = new CustomParameter({ + const customParameter: CustomParameterEntity = new CustomParameterEntity({ name: 'parameterName', displayName: 'User Friendly Name', default: 'mock', @@ -50,7 +49,7 @@ describe('ExternalTool Entity', () => { regexComment: 'mockComment', isOptional: false, }); - const externalTool: ExternalTool = new ExternalTool({ + const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ name: 'toolName', url: 'mockUrl', logoUrl: 'mockLogoUrl', @@ -61,7 +60,7 @@ describe('ExternalTool Entity', () => { version: 1, }); return { - externalTool, + externalToolEntity, oauth2ToolConfig, lti11ToolConfig, }; @@ -69,34 +68,34 @@ describe('ExternalTool Entity', () => { it('should throw an error by empty constructor', () => { // @ts-expect-error: Test case - const test = () => new ExternalTool(); + const test = () => new ExternalToolEntity(); expect(test).toThrow(); }); it('should create an external Tool by passing required properties', () => { - const { externalTool } = setup(); + const { externalToolEntity } = setup(); - expect(externalTool instanceof ExternalTool).toEqual(true); + expect(externalToolEntity instanceof ExternalToolEntity).toEqual(true); }); it('should create an external Tool with basic configuration by passing required properties', () => { - const { externalTool } = setup(); + const { externalToolEntity } = setup(); - expect(externalTool.config instanceof BasicToolConfig).toEqual(true); + expect(externalToolEntity.config instanceof BasicToolConfigEntity).toEqual(true); }); it('should create an external Tool with oauth2 configuration by passing required properties', () => { - const { externalTool, oauth2ToolConfig } = setup(); - externalTool.config = oauth2ToolConfig; + const { externalToolEntity, oauth2ToolConfig } = setup(); + externalToolEntity.config = oauth2ToolConfig; - expect(externalTool.config instanceof Oauth2ToolConfig).toEqual(true); + expect(externalToolEntity.config instanceof Oauth2ToolConfigEntity).toEqual(true); }); it('should create an external Tool with LTI 1.1 configuration by passing required properties', () => { - const { externalTool, lti11ToolConfig } = setup(); - externalTool.config = lti11ToolConfig; + const { externalToolEntity, lti11ToolConfig } = setup(); + externalToolEntity.config = lti11ToolConfig; - expect(externalTool.config instanceof Lti11ToolConfig).toEqual(true); + expect(externalToolEntity.config instanceof Lti11ToolConfigEntity).toEqual(true); }); }); }); diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts similarity index 50% rename from apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.ts rename to apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 5b3793d3da7..3bd3ed9c30d 100644 --- a/apps/server/src/shared/domain/entity/tools/external-tool/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -1,12 +1,13 @@ import { Embedded, Entity, Property, Unique } from '@mikro-orm/core'; -import { BasicToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from '@shared/domain/entity/tools/external-tool/config'; -import { BaseEntityWithTimestamps } from '../../base.entity'; -import { CustomParameter } from './custom-parameter'; -export type IExternalToolProperties = Readonly>; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { CustomParameterEntity } from './custom-parameter'; +import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } from './config'; + +export type IExternalToolProperties = Readonly>; @Entity({ tableName: 'external_tools' }) -export class ExternalTool extends BaseEntityWithTimestamps { +export class ExternalToolEntity extends BaseEntityWithTimestamps { @Unique() @Property() name: string; @@ -17,11 +18,14 @@ export class ExternalTool extends BaseEntityWithTimestamps { @Property({ nullable: true }) logoUrl?: string; - @Embedded(() => [BasicToolConfig, Oauth2ToolConfig, Lti11ToolConfig]) - config: BasicToolConfig | Oauth2ToolConfig | Lti11ToolConfig; + @Property({ nullable: true }) + logoBase64?: string; + + @Embedded(() => [BasicToolConfigEntity, Oauth2ToolConfigEntity, Lti11ToolConfigEntity]) + config: BasicToolConfigEntity | Oauth2ToolConfigEntity | Lti11ToolConfigEntity; - @Embedded(() => CustomParameter, { array: true, nullable: true }) - parameters?: CustomParameter[]; + @Embedded(() => CustomParameterEntity, { array: true, nullable: true }) + parameters?: CustomParameterEntity[]; @Property() isHidden: boolean; @@ -37,6 +41,7 @@ export class ExternalTool extends BaseEntityWithTimestamps { this.name = props.name; this.url = props.url; this.logoUrl = props.logoUrl; + this.logoBase64 = props.logoBase64; this.config = props.config; this.parameters = props.parameters; this.isHidden = props.isHidden; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/index.ts b/apps/server/src/modules/tool/external-tool/entity/index.ts similarity index 100% rename from apps/server/src/shared/domain/entity/tools/external-tool/index.ts rename to apps/server/src/modules/tool/external-tool/entity/index.ts 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 32df697fab3..7db5c25a252 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,9 +1,13 @@ +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 { ExternalToolRepo } from '@shared/repo'; +import { ToolConfigModule } from '../tool-config.module'; import { + ExternalToolConfigurationService, + ExternalToolLogoService, ExternalToolParameterValidationService, ExternalToolService, ExternalToolServiceMapper, @@ -13,15 +17,23 @@ import { import { CommonToolModule } from '../common'; @Module({ - imports: [CommonToolModule, LoggerModule, OauthProviderServiceModule, EncryptionModule], + imports: [CommonToolModule, ToolConfigModule, LoggerModule, OauthProviderServiceModule, EncryptionModule, HttpModule], providers: [ ExternalToolService, ExternalToolServiceMapper, ExternalToolParameterValidationService, ExternalToolValidationService, ExternalToolVersionService, + ExternalToolConfigurationService, + ExternalToolLogoService, ExternalToolRepo, ], - exports: [ExternalToolService, ExternalToolValidationService, ExternalToolVersionService], + exports: [ + ExternalToolService, + ExternalToolValidationService, + ExternalToolVersionService, + ExternalToolConfigurationService, + ExternalToolLogoService, + ], }) export class ExternalToolModule {} diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts new file mode 100644 index 00000000000..8674cba0412 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts @@ -0,0 +1,44 @@ +import { ExternalToolLogoFetchFailedLoggableException } from './external-tool-logo-fetch-failed-loggable-exception'; + +describe('ExternalToolLogoFetchFailedLoggableException', () => { + describe('constructor', () => { + const setup = () => { + const logoUrl = 'logoUrl'; + + return { logoUrl }; + }; + + it('should create an instance of ExternalToolLogoNotFoundLoggableException', () => { + const { logoUrl } = setup(); + + const loggable = new ExternalToolLogoFetchFailedLoggableException(logoUrl, undefined); + + expect(loggable).toBeInstanceOf(ExternalToolLogoFetchFailedLoggableException); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const logoUrl = 'logoUrl'; + const loggable = new ExternalToolLogoFetchFailedLoggableException(logoUrl, 404); + + return { loggable, logoUrl }; + }; + + it('should return a loggable message', () => { + const { loggable, logoUrl } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_TOOL_LOGO_FETCH_FAILED', + message: 'External tool logo could not been fetched', + stack: loggable.stack, + data: { + logoUrl, + httpStatus: 404, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.ts new file mode 100644 index 00000000000..b44b0cbf9d9 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { BusinessError } from '@shared/common'; + +export class ExternalToolLogoFetchFailedLoggableException extends BusinessError implements Loggable { + constructor(private readonly logoUrl: string, private readonly httpStatus?: HttpStatus) { + super( + { + type: 'EXTERNAL_TOOL_LOGO_FETCH_FAILED', + title: 'External tool logo fetch failed.', + defaultMessage: 'External tool logo could not been fetched.', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_TOOL_LOGO_FETCH_FAILED', + message: 'External tool logo could not been fetched', + stack: this.stack, + data: { + logoUrl: this.logoUrl, + httpStatus: this.httpStatus, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts new file mode 100644 index 00000000000..56d32494f9d --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts @@ -0,0 +1,42 @@ +import { ExternalToolLogoFetchedLoggable } from './external-tool-logo-fetched-loggable'; + +describe('ExternalToolLogoFetchedLoggable', () => { + describe('constructor', () => { + const setup = () => { + const logoUrl = 'logoUrl'; + + return { logoUrl }; + }; + + it('should create an instance of ExternalToolLogoFetchedLoggable', () => { + const { logoUrl } = setup(); + + const loggable = new ExternalToolLogoFetchedLoggable(logoUrl); + + expect(loggable).toBeInstanceOf(ExternalToolLogoFetchedLoggable); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const logoUrl = 'logoUrl'; + const loggable = new ExternalToolLogoFetchedLoggable(logoUrl); + + return { loggable, logoUrl }; + }; + + it('should return a loggable message', () => { + const { loggable, logoUrl } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_TOOL_LOGO_FETCHED', + message: 'External tool logo was fetched', + data: { + logoUrl, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.ts new file mode 100644 index 00000000000..cfdc79ec20e --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.ts @@ -0,0 +1,15 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ExternalToolLogoFetchedLoggable implements Loggable { + constructor(private readonly logoUrl: string) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_TOOL_LOGO_FETCHED', + message: 'External tool logo was fetched', + data: { + logoUrl: this.logoUrl, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts new file mode 100644 index 00000000000..de6c298a3b2 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts @@ -0,0 +1,43 @@ +import { ExternalToolLogoNotFoundLoggableException } from './external-tool-logo-not-found-loggable-exception'; + +describe('ExternalToolLogoNotFoundLoggableException', () => { + describe('constructor', () => { + const setup = () => { + const externalToolId = 'externalToolId'; + + return { externalToolId }; + }; + + it('should create an instance of ExternalToolLogoNotFoundLoggableException', () => { + const { externalToolId } = setup(); + + const loggable = new ExternalToolLogoNotFoundLoggableException(externalToolId); + + expect(loggable).toBeInstanceOf(ExternalToolLogoNotFoundLoggableException); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const externalToolId = 'externalToolId'; + const loggable = new ExternalToolLogoNotFoundLoggableException(externalToolId); + + return { loggable, externalToolId }; + }; + + it('should return a loggable message', () => { + const { loggable, externalToolId } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_TOOL_LOGO_NOT_FOUND', + message: 'External tool logo not found', + stack: loggable.stack, + data: { + externalToolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.ts new file mode 100644 index 00000000000..d6d690431da --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.ts @@ -0,0 +1,19 @@ +import { NotFoundException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ExternalToolLogoNotFoundLoggableException extends NotFoundException implements Loggable { + constructor(private readonly externalToolId: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_TOOL_LOGO_NOT_FOUND', + message: 'External tool logo not found', + stack: this.stack, + data: { + externalToolId: this.externalToolId, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts new file mode 100644 index 00000000000..e22b66d49fd --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts @@ -0,0 +1,52 @@ +import { ExternalToolLogoSizeExceededLoggableException } from './external-tool-logo-size-exceeded-loggable-exception'; + +describe('ExternalToolLogoSizeExceededLoggableException', () => { + describe('constructor', () => { + const setup = () => { + const externalToolId = 'externalToolId'; + const maxExternalToolLogoSizeInBytes = 100; + + return { externalToolId, maxExternalToolLogoSizeInBytes }; + }; + + it('should create an instance of ExternalToolLogoSizeExceededLoggableException', () => { + const { externalToolId, maxExternalToolLogoSizeInBytes } = setup(); + + const loggable = new ExternalToolLogoSizeExceededLoggableException( + externalToolId, + maxExternalToolLogoSizeInBytes + ); + + expect(loggable).toBeInstanceOf(ExternalToolLogoSizeExceededLoggableException); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const externalToolId = 'externalToolId'; + const maxExternalToolLogoSizeInBytes = 100; + const loggable = new ExternalToolLogoSizeExceededLoggableException( + externalToolId, + maxExternalToolLogoSizeInBytes + ); + + return { loggable, externalToolId, maxExternalToolLogoSizeInBytes }; + }; + + it('should return a loggable message', () => { + const { loggable, externalToolId, maxExternalToolLogoSizeInBytes } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_TOOL_LOGO_SIZE_EXCEEDED', + message: 'External tool logo size exceeded', + stack: loggable.stack, + data: { + externalToolId, + maxExternalToolLogoSizeInBytes, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.ts new file mode 100644 index 00000000000..f400be7adaf --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.ts @@ -0,0 +1,31 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ExternalToolLogoSizeExceededLoggableException extends BusinessError implements Loggable { + constructor( + private readonly externalToolId: string | undefined, + private readonly maxExternalToolLogoSizeInBytes: number + ) { + super( + { + type: 'EXTERNAL_TOOL_LOGO_SIZE_EXCEEDED', + title: 'External tool logo size exceeded.', + defaultMessage: 'External tool logo size exceeded.', + }, + HttpStatus.BAD_REQUEST + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_TOOL_LOGO_SIZE_EXCEEDED', + message: 'External tool logo size exceeded', + stack: this.stack, + data: { + externalToolId: this.externalToolId, + maxExternalToolLogoSizeInBytes: this.maxExternalToolLogoSizeInBytes, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts new file mode 100644 index 00000000000..cd5e5fa87d3 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { ExternalToolLogoWrongFileTypeLoggableException } from './external-tool-logo-wrong-file-type-loggable-exception'; + +describe('ExternalToolLogoWrongFileTypeLoggableException', () => { + describe('constructor', () => { + it('should create an instance of ExternalToolLogoSizeExceededLoggableException', () => { + const loggable = new ExternalToolLogoWrongFileTypeLoggableException(); + + expect(loggable).toBeInstanceOf(ExternalToolLogoWrongFileTypeLoggableException); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const loggable = new ExternalToolLogoWrongFileTypeLoggableException(); + + return { loggable }; + }; + + it('should return a loggable message', () => { + const { loggable } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_TOOL_LOGO_WRONG_FILE_TYPE', + message: 'External tool logo has the wrong file type. Only JPEG and PNG files are supported.', + stack: loggable.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.ts new file mode 100644 index 00000000000..c4f4a94257e --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.ts @@ -0,0 +1,24 @@ +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { HttpStatus } from '@nestjs/common'; + +export class ExternalToolLogoWrongFileTypeLoggableException extends BusinessError implements Loggable { + constructor() { + super( + { + type: 'EXTERNAL_TOOL_LOGO_WRONG_FILE_TYPE', + title: 'External tool logo wrong file type.', + defaultMessage: 'External tool logo has the wrong file type. Only JPEG and PNG files are supported.', + }, + HttpStatus.BAD_REQUEST + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_TOOL_LOGO_WRONG_FILE_TYPE', + message: 'External tool logo has the wrong file type. Only JPEG and PNG files are supported.', + stack: this.stack, + }; + } +} diff --git a/apps/server/src/modules/tool/external-tool/loggable/index.ts b/apps/server/src/modules/tool/external-tool/loggable/index.ts new file mode 100644 index 00000000000..67d65be236d --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/loggable/index.ts @@ -0,0 +1,5 @@ +export * from './external-tool-logo-not-found-loggable-exception'; +export * from './external-tool-logo-size-exceeded-loggable-exception'; +export * from './external-tool-logo-fetched-loggable'; +export * from './external-tool-logo-fetch-failed-loggable-exception'; +export * from './external-tool-logo-wrong-file-type-loggable-exception'; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts index 452c1c49840..26240cab6cb 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts @@ -1,30 +1,27 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { SortOrder, SortOrderMap } from '@shared/domain'; + import { - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - SortOrder, - SortOrderMap, - ToolConfigType, -} from '@shared/domain'; -import { - BasicToolConfigDO, - CustomParameterDO, - ExternalToolDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, -} from '@shared/domain/domainobject/tool'; -import { basicToolConfigDOFactory, customParameterDOFactory, externalToolDOFactory } from '@shared/testing'; + basicToolConfigFactory, + customParameterFactory, + externalToolFactory, + lti11ToolConfigFactory, + oauth2ToolConfigFactory, +} from '@shared/testing'; +import { CustomParameter } from '../../common/domain'; import { + CustomParameterLocation, CustomParameterLocationParams, + CustomParameterScope, CustomParameterScopeTypeParams, + CustomParameterType, CustomParameterTypeParams, - ExternalToolSearchQuery, LtiMessageType, LtiPrivacyPermission, TokenEndpointAuthMethod, -} from '../../common/interface'; -import { ExternalToolRequestMapper } from './external-tool-request.mapper'; + ToolConfigType, +} from '../../common/enum'; +import { ExternalToolSearchQuery } from '../../common/interface'; import { BasicToolConfigParams, CustomParameterPostParams, @@ -37,6 +34,8 @@ import { Oauth2ToolConfigCreateParams, SortExternalToolParams, } from '../controller/dto'; +import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; +import { ExternalToolRequestMapper } from './external-tool-request.mapper'; describe('ExternalToolRequestMapper', () => { let module: TestingModule; @@ -54,98 +53,71 @@ describe('ExternalToolRequestMapper', () => { await module.close(); }); - const setup = () => { - const basicConfigParams = new BasicToolConfigParams(); - basicConfigParams.type = ToolConfigType.BASIC; - basicConfigParams.baseUrl = 'mockUrl'; - - const customParameterPostParams = new CustomParameterPostParams(); - customParameterPostParams.name = 'mockName'; - customParameterPostParams.displayName = 'displayName'; - customParameterPostParams.description = 'description'; - customParameterPostParams.defaultValue = 'mockDefault'; - customParameterPostParams.location = CustomParameterLocationParams.PATH; - customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; - customParameterPostParams.type = CustomParameterTypeParams.STRING; - customParameterPostParams.regex = 'mockRegex'; - customParameterPostParams.regexComment = 'mockComment'; - customParameterPostParams.isOptional = false; - - const externalToolCreateParams = new ExternalToolCreateParams(); - externalToolCreateParams.name = 'mockName'; - externalToolCreateParams.url = 'mockUrl'; - externalToolCreateParams.logoUrl = 'mockLogoUrl'; - externalToolCreateParams.parameters = [customParameterPostParams]; - externalToolCreateParams.isHidden = true; - externalToolCreateParams.openNewTab = true; - - const externalToolUpdateParams = new ExternalToolUpdateParams(); - externalToolUpdateParams.id = 'id'; - externalToolUpdateParams.name = 'mockName'; - externalToolUpdateParams.url = 'mockUrl'; - externalToolUpdateParams.logoUrl = 'mockLogoUrl'; - externalToolUpdateParams.parameters = [customParameterPostParams]; - externalToolUpdateParams.isHidden = true; - externalToolUpdateParams.openNewTab = true; - - const basicToolConfigDO: BasicToolConfigDO = basicToolConfigDOFactory.build({ - type: ToolConfigType.BASIC, - baseUrl: 'mockUrl', - }); + describe('mapCreateRequest', () => { + describe('when mapping basic tool', () => { + const setup = () => { + const basicConfigParams = new BasicToolConfigParams(); + basicConfigParams.type = ToolConfigType.BASIC; + basicConfigParams.baseUrl = 'mockUrl'; + + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolCreateParams = new ExternalToolCreateParams(); + externalToolCreateParams.name = 'mockName'; + externalToolCreateParams.url = 'mockUrl'; + externalToolCreateParams.logoUrl = 'mockLogoUrl'; + externalToolCreateParams.parameters = [customParameterPostParams]; + externalToolCreateParams.isHidden = true; + externalToolCreateParams.openNewTab = true; + externalToolCreateParams.config = basicConfigParams; - const customParameterDO: CustomParameterDO = customParameterDOFactory.build({ - name: 'mockName', - displayName: 'displayName', - description: 'description', - default: 'mockDefault', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - }); + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); - const externalToolDOCreate: ExternalToolDO = externalToolDOFactory.build({ - name: 'mockName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - parameters: [customParameterDO], - isHidden: true, - openNewTab: true, - version: 1, - config: basicToolConfigDO, - }); + const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build({ + type: ToolConfigType.BASIC, + baseUrl: 'mockUrl', + }); - const externalToolDOUpdate: ExternalToolDO = externalToolDOFactory.buildWithId( - { - name: 'mockName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - parameters: [customParameterDO], - isHidden: true, - openNewTab: true, - version: 1, - config: basicToolConfigDO, - }, - externalToolUpdateParams.id - ); - - return { - externalToolCreateParams, - externalToolUpdateParams, - externalToolDOCreate, - externalToolDOUpdate, - basicToolConfigDO, - basicConfigParams, - }; - }; + const externalToolDOCreate: ExternalTool = externalToolFactory.build({ + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: basicToolConfigDO, + }); + + return { + externalToolCreateParams, + externalToolDOCreate, + }; + }; - describe('mapCreateRequest', () => { - describe('when mapping basic tool', () => { it('should map the request to external tool DO with basicConfig', () => { - const { externalToolCreateParams, externalToolDOCreate, basicConfigParams } = setup(); - externalToolCreateParams.config = basicConfigParams; + const { externalToolCreateParams, externalToolDOCreate } = setup(); const result = mapper.mapCreateRequest(externalToolCreateParams, 1); @@ -154,7 +126,7 @@ describe('ExternalToolRequestMapper', () => { }); describe('when mapping lti tool', () => { - const ltiSetup = () => { + const setup = () => { const lti11ConfigParams = new Lti11ToolConfigCreateParams(); lti11ConfigParams.type = ToolConfigType.LTI11; lti11ConfigParams.baseUrl = 'mockUrl'; @@ -163,8 +135,9 @@ describe('ExternalToolRequestMapper', () => { lti11ConfigParams.resource_link_id = 'mockLink'; lti11ConfigParams.lti_message_type = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; lti11ConfigParams.privacy_permission = LtiPrivacyPermission.NAME; + lti11ConfigParams.launch_presentation_locale = 'de-DE'; - const lti11ToolConfigDO: Lti11ToolConfigDO = new Lti11ToolConfigDO({ + const lti11ToolConfigDO: Lti11ToolConfig = new Lti11ToolConfig({ privacy_permission: LtiPrivacyPermission.NAME, secret: 'mockSecret', key: 'mockKey', @@ -172,15 +145,62 @@ describe('ExternalToolRequestMapper', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', + launch_presentation_locale: 'de-DE', + }); + + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolCreateParams = new ExternalToolCreateParams(); + externalToolCreateParams.name = 'mockName'; + externalToolCreateParams.url = 'mockUrl'; + externalToolCreateParams.logoUrl = 'mockLogoUrl'; + externalToolCreateParams.parameters = [customParameterPostParams]; + externalToolCreateParams.isHidden = true; + externalToolCreateParams.openNewTab = true; + externalToolCreateParams.config = lti11ConfigParams; + + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalToolDOCreate: ExternalTool = externalToolFactory.build({ + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: lti11ToolConfigDO, }); - return { lti11ToolConfigDO, lti11ConfigParams }; + + return { + externalToolCreateParams, + externalToolDOCreate, + }; }; it('should map the request to external tool DO with lti11 config', () => { - const { lti11ToolConfigDO, lti11ConfigParams } = ltiSetup(); const { externalToolCreateParams, externalToolDOCreate } = setup(); - externalToolCreateParams.config = lti11ConfigParams; - externalToolDOCreate.config = lti11ToolConfigDO; const result = mapper.mapCreateRequest(externalToolCreateParams, 1); @@ -189,7 +209,7 @@ describe('ExternalToolRequestMapper', () => { }); describe('when mapping oauth tool', () => { - const oauthSetup = () => { + const setup = () => { const oauth2ConfigParams = new Oauth2ToolConfigCreateParams(); oauth2ConfigParams.type = ToolConfigType.OAUTH2; oauth2ConfigParams.baseUrl = 'mockUrl'; @@ -201,7 +221,7 @@ describe('ExternalToolRequestMapper', () => { oauth2ConfigParams.redirectUris = ['mockUri']; oauth2ConfigParams.tokenEndpointAuthMethod = TokenEndpointAuthMethod.CLIENT_SECRET_POST; - const oauth2ToolConfigDO: Oauth2ToolConfigDO = new Oauth2ToolConfigDO({ + const oauth2ToolConfigDO: Oauth2ToolConfig = oauth2ToolConfigFactory.build({ clientId: 'mockId', type: ToolConfigType.OAUTH2, baseUrl: 'mockUrl', @@ -213,17 +233,59 @@ describe('ExternalToolRequestMapper', () => { tokenEndpointAuthMethod: TokenEndpointAuthMethod.CLIENT_SECRET_POST, }); + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolCreateParams = new ExternalToolCreateParams(); + externalToolCreateParams.name = 'mockName'; + externalToolCreateParams.url = 'mockUrl'; + externalToolCreateParams.logoUrl = 'mockLogoUrl'; + externalToolCreateParams.parameters = [customParameterPostParams]; + externalToolCreateParams.isHidden = true; + externalToolCreateParams.openNewTab = true; + externalToolCreateParams.config = oauth2ConfigParams; + + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalToolDOCreate: ExternalTool = externalToolFactory.build({ + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: oauth2ToolConfigDO, + }); + return { - oauth2ConfigParams, - oauth2ToolConfigDO, + externalToolCreateParams, + externalToolDOCreate, }; }; it('should map the request to external tool DO with oauth2', () => { - const { oauth2ConfigParams, oauth2ToolConfigDO } = oauthSetup(); const { externalToolCreateParams, externalToolDOCreate } = setup(); - externalToolCreateParams.config = oauth2ConfigParams; - externalToolDOCreate.config = oauth2ToolConfigDO; const result = mapper.mapCreateRequest(externalToolCreateParams, 1); @@ -234,10 +296,74 @@ describe('ExternalToolRequestMapper', () => { describe('mapUpdateRequest', () => { describe('when mapping basic tool', () => { - it('should map the request to external tool DO with basicConfig', () => { - const { externalToolUpdateParams, externalToolDOUpdate, basicConfigParams } = setup(); + const setup = () => { + const basicConfigParams = new BasicToolConfigParams(); + basicConfigParams.type = ToolConfigType.BASIC; + basicConfigParams.baseUrl = 'mockUrl'; + + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolUpdateParams = new ExternalToolUpdateParams(); + externalToolUpdateParams.id = 'id'; + externalToolUpdateParams.name = 'mockName'; + externalToolUpdateParams.url = 'mockUrl'; + externalToolUpdateParams.logoUrl = 'mockLogoUrl'; + externalToolUpdateParams.parameters = [customParameterPostParams]; + externalToolUpdateParams.isHidden = true; + externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = basicConfigParams; + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build({ + type: ToolConfigType.BASIC, + baseUrl: 'mockUrl', + }); + + const externalToolDOUpdate: ExternalTool = externalToolFactory.buildWithId( + { + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: basicToolConfigDO, + }, + externalToolUpdateParams.id + ); + + return { + externalToolUpdateParams, + externalToolDOUpdate, + }; + }; + + it('should map the request to external tool DO with basicConfig', () => { + const { externalToolUpdateParams, externalToolDOUpdate } = setup(); + const result = mapper.mapUpdateRequest(externalToolUpdateParams, 1); expect(result).toEqual(externalToolDOUpdate); @@ -245,7 +371,7 @@ describe('ExternalToolRequestMapper', () => { }); describe('when mapping lti tool', () => { - const ltiSetup = () => { + const setup = () => { const lti11ConfigParams = new Lti11ToolConfigUpdateParams(); lti11ConfigParams.type = ToolConfigType.LTI11; lti11ConfigParams.baseUrl = 'mockUrl'; @@ -254,8 +380,9 @@ describe('ExternalToolRequestMapper', () => { lti11ConfigParams.resource_link_id = 'mockLink'; lti11ConfigParams.lti_message_type = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; lti11ConfigParams.privacy_permission = LtiPrivacyPermission.NAME; + lti11ConfigParams.launch_presentation_locale = 'de-DE'; - const lti11ToolConfigDO: Lti11ToolConfigDO = new Lti11ToolConfigDO({ + const lti11ToolConfigDO: Lti11ToolConfig = lti11ToolConfigFactory.build({ privacy_permission: LtiPrivacyPermission.NAME, secret: 'mockSecret', key: 'mockKey', @@ -263,16 +390,66 @@ describe('ExternalToolRequestMapper', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', + launch_presentation_locale: 'de-DE', }); - return { lti11ToolConfigDO, lti11ConfigParams }; + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolUpdateParams = new ExternalToolUpdateParams(); + externalToolUpdateParams.id = 'id'; + externalToolUpdateParams.name = 'mockName'; + externalToolUpdateParams.url = 'mockUrl'; + externalToolUpdateParams.logoUrl = 'mockLogoUrl'; + externalToolUpdateParams.parameters = [customParameterPostParams]; + externalToolUpdateParams.isHidden = true; + externalToolUpdateParams.openNewTab = true; + externalToolUpdateParams.config = lti11ConfigParams; + + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalToolDOUpdate: ExternalTool = externalToolFactory.buildWithId( + { + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: lti11ToolConfigDO, + }, + externalToolUpdateParams.id + ); + + return { + externalToolUpdateParams, + externalToolDOUpdate, + }; }; it('should map the request to external tool DO with lti11 config', () => { - const { lti11ToolConfigDO, lti11ConfigParams } = ltiSetup(); const { externalToolUpdateParams, externalToolDOUpdate } = setup(); - externalToolUpdateParams.config = lti11ConfigParams; - externalToolDOUpdate.config = lti11ToolConfigDO; const result = mapper.mapUpdateRequest(externalToolUpdateParams, 1); @@ -281,7 +458,7 @@ describe('ExternalToolRequestMapper', () => { }); describe('when mapping oauth tool', () => { - const oauthSetup = () => { + const setup = () => { const oauth2ConfigParams = new Oauth2ToolConfigCreateParams(); oauth2ConfigParams.type = ToolConfigType.OAUTH2; oauth2ConfigParams.baseUrl = 'mockUrl'; @@ -293,7 +470,7 @@ describe('ExternalToolRequestMapper', () => { oauth2ConfigParams.redirectUris = ['mockUri']; oauth2ConfigParams.tokenEndpointAuthMethod = TokenEndpointAuthMethod.CLIENT_SECRET_POST; - const oauth2ToolConfigDO: Oauth2ToolConfigDO = new Oauth2ToolConfigDO({ + const oauth2ToolConfigDO: Oauth2ToolConfig = oauth2ToolConfigFactory.build({ clientId: 'mockId', type: ToolConfigType.OAUTH2, baseUrl: 'mockUrl', @@ -305,17 +482,63 @@ describe('ExternalToolRequestMapper', () => { tokenEndpointAuthMethod: TokenEndpointAuthMethod.CLIENT_SECRET_POST, }); + const customParameterPostParams = new CustomParameterPostParams(); + customParameterPostParams.name = 'mockName'; + customParameterPostParams.displayName = 'displayName'; + customParameterPostParams.description = 'description'; + customParameterPostParams.defaultValue = 'mockDefault'; + customParameterPostParams.location = CustomParameterLocationParams.PATH; + customParameterPostParams.scope = CustomParameterScopeTypeParams.SCHOOL; + customParameterPostParams.type = CustomParameterTypeParams.STRING; + customParameterPostParams.regex = 'mockRegex'; + customParameterPostParams.regexComment = 'mockComment'; + customParameterPostParams.isOptional = false; + + const externalToolUpdateParams = new ExternalToolUpdateParams(); + externalToolUpdateParams.id = 'id'; + externalToolUpdateParams.name = 'mockName'; + externalToolUpdateParams.url = 'mockUrl'; + externalToolUpdateParams.logoUrl = 'mockLogoUrl'; + externalToolUpdateParams.parameters = [customParameterPostParams]; + externalToolUpdateParams.isHidden = true; + externalToolUpdateParams.openNewTab = true; + externalToolUpdateParams.config = oauth2ConfigParams; + + const customParameterDO: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalToolDOUpdate: ExternalTool = externalToolFactory.buildWithId( + { + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterDO], + isHidden: true, + openNewTab: true, + version: 1, + config: oauth2ToolConfigDO, + }, + externalToolUpdateParams.id + ); + return { - oauth2ConfigParams, - oauth2ToolConfigDO, + externalToolUpdateParams, + externalToolDOUpdate, }; }; it('should map the request to external tool DO with oauth2', () => { - const { oauth2ConfigParams, oauth2ToolConfigDO } = oauthSetup(); const { externalToolUpdateParams, externalToolDOUpdate } = setup(); - externalToolUpdateParams.config = oauth2ConfigParams; - externalToolDOUpdate.config = oauth2ToolConfigDO; const result = mapper.mapUpdateRequest(externalToolUpdateParams, 1); @@ -325,35 +548,57 @@ describe('ExternalToolRequestMapper', () => { }); describe('mapSortingQueryToDomain', () => { - it('should map controller sorting query to domain sort order map', () => { - const sortingQuery: SortExternalToolParams = { - sortBy: ExternalToolSortBy.ID, - sortOrder: SortOrder.asc, + describe('when sortBy is given', () => { + const setup = () => { + const sortingQuery: SortExternalToolParams = { + sortBy: ExternalToolSortBy.ID, + sortOrder: SortOrder.asc, + }; + + return { sortingQuery }; }; - const result: SortOrderMap | undefined = mapper.mapSortingQueryToDomain(sortingQuery); + it('should map controller sorting query to domain sort order map', () => { + const { sortingQuery } = setup(); - expect(result).toEqual({ id: SortOrder.asc }); + const result: SortOrderMap | undefined = mapper.mapSortingQueryToDomain(sortingQuery); + + expect(result).toEqual({ id: SortOrder.asc }); + }); }); - it('should map controller sorting query to undefined', () => { - const sortingQuery: SortExternalToolParams = { - sortOrder: SortOrder.asc, + describe('when sortBy is not given', () => { + const setup = () => { + const sortingQuery: SortExternalToolParams = { + sortOrder: SortOrder.asc, + }; + + return { sortingQuery }; }; - const result: SortOrderMap | undefined = mapper.mapSortingQueryToDomain(sortingQuery); + it('should map controller sorting query to undefined', () => { + const { sortingQuery } = setup(); + + const result: SortOrderMap | undefined = mapper.mapSortingQueryToDomain(sortingQuery); - expect(result).toBeUndefined(); + expect(result).toBeUndefined(); + }); }); }); describe('mapExternalToolFilterQueryToExternalToolSearchQuery', () => { - it('should map params to a search query', () => { + const setup = () => { const params: ExternalToolSearchParams = { name: 'name', clientId: 'clientId', }; + return { params }; + }; + + it('should map params to a search query', () => { + const { params } = setup(); + const result: ExternalToolSearchQuery = mapper.mapExternalToolFilterQueryToExternalToolSearchQuery(params); expect(result).toEqual(params); diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts index fc930215241..eed114902a0 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts @@ -1,17 +1,13 @@ import { Injectable } from '@nestjs/common'; +import { SortOrderMap } from '@shared/domain'; import { CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - ExternalToolDO, - SortOrderMap, -} from '@shared/domain'; -import { CustomParameterLocationParams, + CustomParameterScope, CustomParameterScopeTypeParams, + CustomParameterType, CustomParameterTypeParams, - ExternalToolSearchQuery, -} from '../../common/interface'; +} from '../../common/enum'; import { BasicToolConfigParams, CustomParameterPostParams, @@ -25,8 +21,8 @@ import { SortExternalToolParams, } from '../controller/dto'; import { - BasicToolConfig, - CustomParameter, + BasicToolConfigDto, + CustomParameterDto, ExternalToolCreate, ExternalToolUpdate, Lti11ToolConfigCreate, @@ -34,6 +30,8 @@ import { Oauth2ToolConfigCreate, Oauth2ToolConfigUpdate, } from '../uc'; +import { ExternalTool } from '../domain'; +import { ExternalToolSearchQuery } from '../../common/interface'; const scopeMapping: Record = { [CustomParameterScopeTypeParams.GLOBAL]: CustomParameterScope.GLOBAL, @@ -60,7 +58,7 @@ const typeMapping: Record = { @Injectable() export class ExternalToolRequestMapper { public mapUpdateRequest(externalToolUpdateParams: ExternalToolUpdateParams, version = 1): ExternalToolUpdate { - let mappedConfig: BasicToolConfig | Lti11ToolConfigUpdate | Oauth2ToolConfigUpdate; + let mappedConfig: BasicToolConfigDto | Lti11ToolConfigUpdate | Oauth2ToolConfigUpdate; if (externalToolUpdateParams.config instanceof BasicToolConfigParams) { mappedConfig = this.mapRequestToBasicToolConfig(externalToolUpdateParams.config); } else if (externalToolUpdateParams.config instanceof Lti11ToolConfigUpdateParams) { @@ -69,7 +67,7 @@ export class ExternalToolRequestMapper { mappedConfig = this.mapRequestToOauth2ToolConfigUpdate(externalToolUpdateParams.config); } - const mappedCustomParameter: CustomParameter[] = this.mapRequestToCustomParameterDO( + const mappedCustomParameter: CustomParameterDto[] = this.mapRequestToCustomParameterDO( externalToolUpdateParams.parameters ?? [] ); @@ -87,7 +85,7 @@ export class ExternalToolRequestMapper { } public mapCreateRequest(externalToolCreateParams: ExternalToolCreateParams, version = 1): ExternalToolCreate { - let mappedConfig: BasicToolConfig | Lti11ToolConfigCreate | Oauth2ToolConfigCreate; + let mappedConfig: BasicToolConfigDto | Lti11ToolConfigCreate | Oauth2ToolConfigCreate; if (externalToolCreateParams.config instanceof BasicToolConfigParams) { mappedConfig = this.mapRequestToBasicToolConfig(externalToolCreateParams.config); } else if (externalToolCreateParams.config instanceof Lti11ToolConfigCreateParams) { @@ -96,7 +94,7 @@ export class ExternalToolRequestMapper { mappedConfig = this.mapRequestToOauth2ToolConfigCreate(externalToolCreateParams.config); } - const mappedCustomParameter: CustomParameter[] = this.mapRequestToCustomParameterDO( + const mappedCustomParameter: CustomParameterDto[] = this.mapRequestToCustomParameterDO( externalToolCreateParams.parameters ?? [] ); @@ -112,7 +110,7 @@ export class ExternalToolRequestMapper { }; } - private mapRequestToBasicToolConfig(externalToolConfigParams: BasicToolConfigParams): BasicToolConfig { + private mapRequestToBasicToolConfig(externalToolConfigParams: BasicToolConfigParams): BasicToolConfigDto { return { ...externalToolConfigParams }; } @@ -140,7 +138,7 @@ export class ExternalToolRequestMapper { return { ...externalToolConfigParams }; } - private mapRequestToCustomParameterDO(customParameterParams: CustomParameterPostParams[]): CustomParameter[] { + private mapRequestToCustomParameterDO(customParameterParams: CustomParameterPostParams[]): CustomParameterDto[] { return customParameterParams.map((customParameterParam: CustomParameterPostParams) => { return { name: customParameterParam.name, @@ -157,13 +155,13 @@ export class ExternalToolRequestMapper { }); } - mapSortingQueryToDomain(sortingQuery: SortExternalToolParams): SortOrderMap | undefined { + mapSortingQueryToDomain(sortingQuery: SortExternalToolParams): SortOrderMap | undefined { const { sortBy } = sortingQuery; if (sortBy == null) { return undefined; } - const result: SortOrderMap = { + const result: SortOrderMap = { [sortBy]: sortingQuery.sortOrder, }; return result; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts index 2366099ab0a..bdf612f944c 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts @@ -1,133 +1,115 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '@shared/domain'; import { - BasicToolConfigDO, - CustomParameterDO, - ExternalToolDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, -} from '@shared/domain/domainobject/tool'; -import { externalToolDOFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; + basicToolConfigFactory, + customParameterFactory, + externalToolFactory, + lti11ToolConfigFactory, + oauth2ToolConfigFactory, +} from '@shared/testing'; +import { CustomParameter } from '../../common/domain'; import { + CustomParameterLocation, CustomParameterLocationParams, + CustomParameterScope, CustomParameterScopeTypeParams, + CustomParameterType, CustomParameterTypeParams, LtiMessageType, LtiPrivacyPermission, TokenEndpointAuthMethod, ToolConfigType, -} from '../../common/interface'; -import { ExternalToolResponseMapper } from './external-tool-response.mapper'; +} from '../../common/enum'; import { BasicToolConfigResponse, CustomParameterResponse, - ExternalToolConfigurationTemplateResponse, ExternalToolResponse, Lti11ToolConfigResponse, Oauth2ToolConfigResponse, - ToolConfigurationEntryResponse, - ToolConfigurationListResponse, } from '../controller/dto'; +import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; +import { ExternalToolResponseMapper } from './external-tool-response.mapper'; describe('ExternalToolResponseMapper', () => { - let module: TestingModule; - let mapper: ExternalToolResponseMapper; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ExternalToolResponseMapper], - }).compile(); - - mapper = module.get(ExternalToolResponseMapper); - }); + describe('mapToExternalToolResponse', () => { + describe('when mapping basic tool DO', () => { + const setup = () => { + const customParameterResponse: CustomParameterResponse = new CustomParameterResponse({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + defaultValue: 'mockDefault', + location: CustomParameterLocationParams.PATH, + scope: CustomParameterScopeTypeParams.SCHOOL, + type: CustomParameterTypeParams.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); - afterAll(async () => { - await module.close(); - }); + const basicToolConfigResponse: BasicToolConfigResponse = new BasicToolConfigResponse({ + type: ToolConfigType.BASIC, + baseUrl: 'mockUrl', + }); - const setup = () => { - const customParameterResponse: CustomParameterResponse = new CustomParameterResponse({ - name: 'mockName', - displayName: 'displayName', - description: 'description', - defaultValue: 'mockDefault', - location: CustomParameterLocationParams.PATH, - scope: CustomParameterScopeTypeParams.SCHOOL, - type: CustomParameterTypeParams.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - }); - const basicToolConfigResponse: BasicToolConfigResponse = new BasicToolConfigResponse({ - type: ToolConfigType.BASIC, - baseUrl: 'mockUrl', - }); + const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterResponse], + isHidden: true, + openNewTab: true, + version: 1, + config: basicToolConfigResponse, + }); - const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ - id: '1', - name: 'mockName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - parameters: [customParameterResponse], - isHidden: true, - openNewTab: true, - version: 1, - config: basicToolConfigResponse, - }); + const basicToolConfig: BasicToolConfig = basicToolConfigFactory.build({ + type: ToolConfigType.BASIC, + baseUrl: 'mockUrl', + }); - const basicToolConfigDO: BasicToolConfigDO = new BasicToolConfigDO({ - type: ToolConfigType.BASIC, - baseUrl: 'mockUrl', - }); + const customParameter: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); - const customParameterDO: CustomParameterDO = new CustomParameterDO({ - name: 'mockName', - displayName: 'displayName', - description: 'description', - default: 'mockDefault', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - }); - const externalToolDO: ExternalToolDO = new ExternalToolDO({ - id: '1', - name: 'mockName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - parameters: [customParameterDO], - isHidden: true, - openNewTab: true, - version: 1, - config: basicToolConfigDO, - }); + const externalTool: ExternalTool = externalToolFactory.build({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + config: basicToolConfig, + }); - return { - externalToolResponse, - externalToolDO, - basicToolConfigDO, - basicToolConfigResponse, - }; - }; + return { + externalToolResponse, + externalTool, + }; + }; - describe('mapToResponse', () => { - describe('when mapping basic tool DO', () => { it('should map a basic tool do to a basic tool response', () => { - const { externalToolDO, externalToolResponse, basicToolConfigDO, basicToolConfigResponse } = setup(); - externalToolDO.config = basicToolConfigDO; - externalToolResponse.config = basicToolConfigResponse; + const { externalTool, externalToolResponse } = setup(); - const result: ExternalToolResponse = mapper.mapToExternalToolResponse(externalToolDO); + const result: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(externalTool); expect(result).toEqual(externalToolResponse); }); }); describe('when mapping oauth tool DO', () => { - const oauthSetup = () => { - const oauth2ToolConfigDO: Oauth2ToolConfigDO = new Oauth2ToolConfigDO({ + const setup = () => { + const oauth2ToolConfigDO: Oauth2ToolConfig = oauth2ToolConfigFactory.build({ clientId: 'mockId', skipConsent: false, type: ToolConfigType.OAUTH2, @@ -150,33 +132,81 @@ describe('ExternalToolResponseMapper', () => { redirectUris: ['redirectUri'], }); + const customParameterResponse: CustomParameterResponse = new CustomParameterResponse({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + defaultValue: 'mockDefault', + location: CustomParameterLocationParams.PATH, + scope: CustomParameterScopeTypeParams.SCHOOL, + type: CustomParameterTypeParams.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterResponse], + isHidden: true, + openNewTab: true, + version: 1, + config: oauth2ToolConfigResponse, + }); + + const customParameter: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + + const externalTool: ExternalTool = externalToolFactory.build({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + config: oauth2ToolConfigDO, + }); + return { - oauth2ToolConfigResponse, - oauth2ToolConfigDO, + externalToolResponse, + externalTool, }; }; it('should map a oauth2 tool do to a oauth2 tool response', () => { - const { oauth2ToolConfigDO, oauth2ToolConfigResponse } = oauthSetup(); - const { externalToolDO, externalToolResponse } = setup(); - externalToolDO.config = oauth2ToolConfigDO; - externalToolResponse.config = oauth2ToolConfigResponse; + const { externalTool, externalToolResponse } = setup(); - const result: ExternalToolResponse = mapper.mapToExternalToolResponse(externalToolDO); + const result: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(externalTool); expect(result).toEqual(externalToolResponse); }); }); describe('when mapping lti tool DO', () => { - const ltiSetup = () => { - const lti11ToolConfigDO: Lti11ToolConfigDO = new Lti11ToolConfigDO({ + const setup = () => { + const lti11ToolConfigDO: Lti11ToolConfig = lti11ToolConfigFactory.build({ secret: 'mockSecret', key: 'mockKey', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.NAME, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', + launch_presentation_locale: 'de-DE', }); const lti11ToolConfigResponse: Lti11ToolConfigResponse = new Lti11ToolConfigResponse({ @@ -185,90 +215,71 @@ describe('ExternalToolResponseMapper', () => { privacy_permission: LtiPrivacyPermission.NAME, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', + launch_presentation_locale: 'de-DE', + resource_link_id: 'linkId', }); - return { lti11ToolConfigDO, lti11ToolConfigResponse }; - }; - it('should map a lti11 tool DO to a lti11 tool response', () => { - const { lti11ToolConfigDO, lti11ToolConfigResponse } = ltiSetup(); - const { externalToolDO, externalToolResponse } = setup(); - externalToolDO.config = lti11ToolConfigDO; - externalToolResponse.config = lti11ToolConfigResponse; - - const result: ExternalToolResponse = mapper.mapToExternalToolResponse(externalToolDO); + const customParameterResponse: CustomParameterResponse = new CustomParameterResponse({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + defaultValue: 'mockDefault', + location: CustomParameterLocationParams.PATH, + scope: CustomParameterScopeTypeParams.SCHOOL, + type: CustomParameterTypeParams.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); - expect(result).toEqual(externalToolResponse); - }); - }); - }); + const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameterResponse], + isHidden: true, + openNewTab: true, + version: 1, + config: lti11ToolConfigResponse, + }); - describe('mapExternalToolDOsToToolConfigurationListResponse is called', () => { - describe('when mapping from ExternalToolDOs to ToolConfigurationListResponse', () => { - it('should map from ExternalToolDOs to ToolConfigurationListResponse', () => { - const externalToolDOs: ExternalToolDO[] = externalToolDOFactory.buildList(3, { - id: 'toolId', - name: 'toolName', - logoUrl: 'logoUrl', + const customParameter: CustomParameter = customParameterFactory.build({ + name: 'mockName', + displayName: 'displayName', + description: 'description', + default: 'mockDefault', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, }); - const expectedResponse: ToolConfigurationEntryResponse = new ToolConfigurationEntryResponse({ - id: 'toolId', - name: 'toolName', - logoUrl: 'logoUrl', + const externalTool: ExternalTool = externalToolFactory.build({ + id: '1', + name: 'mockName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + config: lti11ToolConfigDO, }); - const result: ToolConfigurationListResponse = - mapper.mapExternalToolDOsToToolConfigurationListResponse(externalToolDOs); - - expect(result.data).toEqual(expect.arrayContaining([expectedResponse, expectedResponse, expectedResponse])); - }); - }); - }); + return { + externalToolResponse, + externalTool, + }; + }; - describe('mapToConfigurationTemplateResponse is called', () => { - describe('when ExternalToolDO is given', () => { - it('should map ExternalToolDO to ExternalToolConfigurationTemplateResponse', () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory - .withCustomParameters(1, { - displayName: 'displayName', - description: 'description', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - location: CustomParameterLocation.PATH, - name: 'customParameter', - isOptional: false, - default: 'defaultValue', - }) - .buildWithId( - { - name: 'toolName', - logoUrl: 'logoUrl', - version: 1, - }, - 'toolId' - ); - const expected: ExternalToolConfigurationTemplateResponse = new ExternalToolConfigurationTemplateResponse({ - id: 'toolId', - name: 'toolName', - logoUrl: 'logoUrl', - parameters: [ - new CustomParameterResponse({ - scope: CustomParameterScopeTypeParams.SCHOOL, - type: CustomParameterTypeParams.STRING, - location: CustomParameterLocationParams.PATH, - name: 'customParameter', - displayName: 'displayName', - description: 'description', - isOptional: false, - defaultValue: 'defaultValue', - }), - ], - version: 1, - }); + it('should map an lti11 tool DO to an lti11 tool response', () => { + const { externalTool, externalToolResponse } = setup(); - const result: ExternalToolConfigurationTemplateResponse = - mapper.mapToConfigurationTemplateResponse(externalToolDO); + const result: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(externalTool); - expect(result).toEqual(expected); + expect(result).toEqual(externalToolResponse); }); }); }); diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts index 88b77e2aeab..2885c2ea0c0 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts @@ -1,29 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolReference } from '@shared/domain'; -import { - BasicToolConfigDO, - CustomParameterDO, - ExternalToolDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, -} from '@shared/domain/domainobject/tool'; +import { CustomParameter } from '../../common/domain'; import { + CustomParameterLocation, CustomParameterLocationParams, + CustomParameterScope, CustomParameterScopeTypeParams, + CustomParameterType, CustomParameterTypeParams, -} from '../../common/interface'; +} from '../../common/enum'; +import { statusMapping } from '../../school-external-tool/mapper'; import { BasicToolConfigResponse, CustomParameterResponse, - ExternalToolConfigurationTemplateResponse, ExternalToolResponse, Lti11ToolConfigResponse, Oauth2ToolConfigResponse, - ToolConfigurationEntryResponse, - ToolConfigurationListResponse, ToolReferenceResponse, } from '../controller/dto'; -import { statusMapping } from '../../school-external-tool/mapper'; +import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig, ToolReference } from '../domain'; const scopeMapping: Record = { [CustomParameterScope.GLOBAL]: CustomParameterScopeTypeParams.GLOBAL, @@ -49,47 +43,47 @@ const typeMapping: Record = { @Injectable() export class ExternalToolResponseMapper { - mapToExternalToolResponse(externalToolDO: ExternalToolDO): ExternalToolResponse { + static mapToExternalToolResponse(externalTool: ExternalTool): ExternalToolResponse { let mappedConfig: BasicToolConfigResponse | Lti11ToolConfigResponse | Oauth2ToolConfigResponse; - if (externalToolDO.config instanceof BasicToolConfigDO) { - mappedConfig = this.mapBasicToolConfigDOToResponse(externalToolDO.config); - } else if (externalToolDO.config instanceof Lti11ToolConfigDO) { - mappedConfig = this.mapLti11ToolConfigDOToResponse(externalToolDO.config); + if (externalTool.config instanceof BasicToolConfig) { + mappedConfig = this.mapBasicToolConfigDOToResponse(externalTool.config); + } else if (externalTool.config instanceof Lti11ToolConfig) { + mappedConfig = this.mapLti11ToolConfigDOToResponse(externalTool.config); } else { - mappedConfig = this.mapOauth2ToolConfigDOToResponse(externalToolDO.config); + mappedConfig = this.mapOauth2ToolConfigDOToResponse(externalTool.config); } - const mappedCustomParameter: CustomParameterResponse[] = this.mapCustomParameterDOToResponse( - externalToolDO.parameters ?? [] + const mappedCustomParameter: CustomParameterResponse[] = this.mapCustomParameterToResponse( + externalTool.parameters ?? [] ); return new ExternalToolResponse({ - id: externalToolDO.id ?? '', - name: externalToolDO.name, - url: externalToolDO.url, - logoUrl: externalToolDO.logoUrl, + id: externalTool.id ?? '', + name: externalTool.name, + url: externalTool.url, + logoUrl: externalTool.logoUrl, config: mappedConfig, parameters: mappedCustomParameter, - isHidden: externalToolDO.isHidden, - openNewTab: externalToolDO.openNewTab, - version: externalToolDO.version, + isHidden: externalTool.isHidden, + openNewTab: externalTool.openNewTab, + version: externalTool.version, }); } - private mapBasicToolConfigDOToResponse(externalToolConfigDO: BasicToolConfigDO): BasicToolConfigResponse { + private static mapBasicToolConfigDOToResponse(externalToolConfigDO: BasicToolConfig): BasicToolConfigResponse { return new BasicToolConfigResponse({ ...externalToolConfigDO }); } - private mapLti11ToolConfigDOToResponse(externalToolConfigDO: Lti11ToolConfigDO): Lti11ToolConfigResponse { + private static mapLti11ToolConfigDOToResponse(externalToolConfigDO: Lti11ToolConfig): Lti11ToolConfigResponse { return new Lti11ToolConfigResponse({ ...externalToolConfigDO }); } - private mapOauth2ToolConfigDOToResponse(externalToolConfigDO: Oauth2ToolConfigDO): Oauth2ToolConfigResponse { + private static mapOauth2ToolConfigDOToResponse(externalToolConfigDO: Oauth2ToolConfig): Oauth2ToolConfigResponse { return new Oauth2ToolConfigResponse({ ...externalToolConfigDO }); } - private mapCustomParameterDOToResponse(customParameterDOS: CustomParameterDO[]): CustomParameterResponse[] { - return customParameterDOS.map((customParameterDO: CustomParameterDO) => { + static mapCustomParameterToResponse(customParameters: CustomParameter[]): CustomParameterResponse[] { + return customParameters.map((customParameterDO: CustomParameter) => { return { name: customParameterDO.name, displayName: customParameterDO.displayName, @@ -105,38 +99,7 @@ export class ExternalToolResponseMapper { }); } - mapExternalToolDOsToToolConfigurationListResponse(externalTools: ExternalToolDO[]): ToolConfigurationListResponse { - return new ToolConfigurationListResponse(this.mapExternalToolDOsToToolConfigurationResponses(externalTools)); - } - - private mapExternalToolDOsToToolConfigurationResponses( - externalTools: ExternalToolDO[] - ): ToolConfigurationEntryResponse[] { - return externalTools.map( - (tool: ExternalToolDO) => - new ToolConfigurationEntryResponse({ - id: tool.id ?? '', - name: tool.name, - logoUrl: tool.logoUrl, - }) - ); - } - - mapToConfigurationTemplateResponse(externalToolDO: ExternalToolDO): ExternalToolConfigurationTemplateResponse { - const mappedCustomParameter: CustomParameterResponse[] = this.mapCustomParameterDOToResponse( - externalToolDO.parameters ?? [] - ); - - return new ExternalToolConfigurationTemplateResponse({ - id: externalToolDO.id ?? '', - name: externalToolDO.name, - logoUrl: externalToolDO.logoUrl, - parameters: mappedCustomParameter, - version: externalToolDO.version, - }); - } - - mapToToolReferenceResponses(toolReferences: ToolReference[]): ToolReferenceResponse[] { + static mapToToolReferenceResponses(toolReferences: ToolReference[]): ToolReferenceResponse[] { const toolReferenceResponses: ToolReferenceResponse[] = toolReferences.map((toolReference: ToolReference) => this.mapToToolReferenceResponse(toolReference) ); @@ -144,7 +107,7 @@ export class ExternalToolResponseMapper { return toolReferenceResponses; } - private mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { + private static mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { const response = new ToolReferenceResponse({ contextToolId: toolReference.contextToolId, displayName: toolReference.displayName, diff --git a/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts new file mode 100644 index 00000000000..a3aebbd6302 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts @@ -0,0 +1,72 @@ +import { + ContextExternalToolConfigurationTemplateListResponse, + ContextExternalToolConfigurationTemplateResponse, + SchoolExternalToolConfigurationTemplateListResponse, + SchoolExternalToolConfigurationTemplateResponse, +} from '../controller/dto'; +import { ExternalTool } from '../domain'; +import { ContextExternalToolTemplateInfo } from '../uc'; +import { ExternalToolResponseMapper } from './external-tool-response.mapper'; + +export class ToolConfigurationMapper { + static mapToSchoolExternalToolConfigurationTemplateResponse( + externalTool: ExternalTool + ): SchoolExternalToolConfigurationTemplateResponse { + const mapped = new SchoolExternalToolConfigurationTemplateResponse({ + externalToolId: externalTool.id ?? '', + name: externalTool.name, + logoUrl: externalTool.logoUrl, + parameters: externalTool.parameters + ? ExternalToolResponseMapper.mapCustomParameterToResponse(externalTool.parameters) + : [], + version: externalTool.version, + }); + + return mapped; + } + + static mapToSchoolExternalToolConfigurationTemplateListResponse( + externalTools: ExternalTool[] + ): SchoolExternalToolConfigurationTemplateListResponse { + const mappedTools = externalTools.map( + (tool): SchoolExternalToolConfigurationTemplateResponse => + this.mapToSchoolExternalToolConfigurationTemplateResponse(tool) + ); + + const mapped = new SchoolExternalToolConfigurationTemplateListResponse(mappedTools); + + return mapped; + } + + static mapToContextExternalToolConfigurationTemplateResponse( + toolInfo: ContextExternalToolTemplateInfo + ): ContextExternalToolConfigurationTemplateResponse { + const { externalTool, schoolExternalTool } = toolInfo; + + const mapped = new ContextExternalToolConfigurationTemplateResponse({ + externalToolId: externalTool.id ?? '', + schoolExternalToolId: schoolExternalTool.id ?? '', + name: externalTool.name, + logoUrl: externalTool.logoUrl, + parameters: externalTool.parameters + ? ExternalToolResponseMapper.mapCustomParameterToResponse(externalTool.parameters) + : [], + version: externalTool.version, + }); + + return mapped; + } + + static mapToContextExternalToolConfigurationTemplateListResponse( + toolInfos: ContextExternalToolTemplateInfo[] + ): ContextExternalToolConfigurationTemplateListResponse { + const mappedTools = toolInfos.map( + (tool): ContextExternalToolConfigurationTemplateResponse => + this.mapToContextExternalToolConfigurationTemplateResponse(tool) + ); + + const mapped = new ContextExternalToolConfigurationTemplateListResponse(mappedTools); + + return mapped; + } +} diff --git a/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts index a712278d1e5..ec982467578 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts @@ -1,9 +1,11 @@ -import { ContextExternalToolDO, ExternalToolDO, ToolConfigurationStatus, ToolReference } from '@shared/domain'; +import { ExternalTool, ToolReference } from '../domain'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ToolConfigurationStatus } from '../../common/enum'; export class ToolReferenceMapper { static mapToToolReference( - externalTool: ExternalToolDO, - contextExternalTool: ContextExternalToolDO, + externalTool: ExternalTool, + contextExternalTool: ContextExternalTool, status: ToolConfigurationStatus ): ToolReference { const toolReference = new ToolReference({ diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts new file mode 100644 index 00000000000..6d0064c1136 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts @@ -0,0 +1,213 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId, Page } from '@shared/domain'; +import { + contextExternalToolFactory, + customParameterFactory, + externalToolFactory, + schoolExternalToolFactory, + setupEntities, +} from '@shared/testing'; +import { CustomParameter } from '../../common/domain'; +import { CustomParameterScope } from '../../common/enum'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../domain'; +import { ContextExternalToolTemplateInfo } from '../uc'; +import { ExternalToolConfigurationService } from './external-tool-configuration.service'; + +describe('ExternalToolConfigurationService', () => { + let module: TestingModule; + let service: ExternalToolConfigurationService; + + let toolFeatures: IToolFeatures; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + ExternalToolConfigurationService, + { + provide: ToolFeatures, + useValue: { + contextConfigurationEnabled: false, + }, + }, + ], + }).compile(); + + service = module.get(ExternalToolConfigurationService); + toolFeatures = module.get(ToolFeatures); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('filterForAvailableTools', () => { + describe('when tools are given', () => { + const setup = () => { + const notHiddenTools = [ + externalToolFactory.buildWithId(undefined, 'usedToolId'), + externalToolFactory.buildWithId(undefined, 'unusedToolId'), + ]; + const externalTools: ExternalTool[] = [ + ...notHiddenTools, + externalToolFactory.buildWithId({ isHidden: true }, 'hiddenToolId'), + ]; + const externalToolsPage: Page = new Page(externalTools, externalTools.length); + const toolIdsInUse: EntityId[] = ['usedToolId', 'hiddenToolId']; + + return { externalToolsPage, toolIdsInUse, notHiddenTools }; + }; + + it('should filter out hidden tools', () => { + const { externalToolsPage, toolIdsInUse } = setup(); + + const result: ExternalTool[] = service.filterForAvailableTools(externalToolsPage, toolIdsInUse); + + expect(result.some((tool) => tool.id === 'usedToolId')).toBe(false); + }); + + it('should include unused tools', () => { + const { externalToolsPage, toolIdsInUse } = setup(); + + const result: ExternalTool[] = service.filterForAvailableTools(externalToolsPage, toolIdsInUse); + + expect(result.some((tool) => tool.id === 'unusedToolId')).toBe(true); + }); + + it('should not filter tools when none are in use', () => { + const { externalToolsPage, notHiddenTools } = setup(); + + const result: ExternalTool[] = service.filterForAvailableTools(externalToolsPage, []); + + expect(result.length).toBe(notHiddenTools.length); + }); + }); + }); + + describe('filterForAvailableSchoolExternalTools', () => { + describe('when context configuration is enabled', () => { + const setup = () => { + toolFeatures.contextConfigurationEnabled = true; + const usedSchoolExternalToolId = 'usedSchoolExternalToolId'; + const schoolExternalTools: SchoolExternalTool[] = [ + schoolExternalToolFactory.buildWithId(undefined, usedSchoolExternalToolId), + schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), + ]; + const contextExternalToolsInUse: ContextExternalTool[] = [ + contextExternalToolFactory.withSchoolExternalToolRef(usedSchoolExternalToolId).buildWithId(), + contextExternalToolFactory.buildWithId(undefined, 'unusedContextExternalToolId'), + ]; + + return { schoolExternalTools, contextExternalToolsInUse }; + }; + + it('should include all school external tools', () => { + const { schoolExternalTools, contextExternalToolsInUse } = setup(); + + const result: SchoolExternalTool[] = service.filterForAvailableSchoolExternalTools( + schoolExternalTools, + contextExternalToolsInUse + ); + + expect(result).toEqual(schoolExternalTools); + }); + }); + + describe('when context configuration is disabled', () => { + const setup = () => { + toolFeatures.contextConfigurationEnabled = false; + const usedSchoolExternalToolId = 'usedSchoolExternalToolId'; + const unusedSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId( + undefined, + 'unusedSchoolExternalToolId' + ); + const schoolExternalTools: SchoolExternalTool[] = [ + schoolExternalToolFactory.buildWithId(undefined, usedSchoolExternalToolId), + unusedSchoolExternalTool, + ]; + const contextExternalToolsInUse: ContextExternalTool[] = [ + contextExternalToolFactory.withSchoolExternalToolRef(usedSchoolExternalToolId).buildWithId(), + contextExternalToolFactory.buildWithId(undefined, 'unusedContextExternalToolId'), + ]; + + return { schoolExternalTools, contextExternalToolsInUse, unusedSchoolExternalTool }; + }; + + it('should filter out school external tools in use', () => { + const { schoolExternalTools, contextExternalToolsInUse, unusedSchoolExternalTool } = setup(); + + const result: SchoolExternalTool[] = service.filterForAvailableSchoolExternalTools( + schoolExternalTools, + contextExternalToolsInUse + ); + + expect(result).toEqual([unusedSchoolExternalTool]); + }); + }); + }); + + describe('filterForAvailableExternalTools', () => { + describe('when filtering for available external tools', () => { + const setup = () => { + const usedExternalToolId = 'usedToolId'; + const usedExternalToolHiddenId = 'usedToolId'; + const externalTools: ExternalTool[] = [ + externalToolFactory.buildWithId(undefined, usedExternalToolId), + externalToolFactory.buildWithId(undefined, 'unusedToolId'), + externalToolFactory.buildWithId({ isHidden: true }, usedExternalToolHiddenId), + ]; + const availableSchoolExternalTools: SchoolExternalTool[] = [ + schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolId }, 'usedSchoolExternalToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), + schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolHiddenId }, 'usedSchoolExternalToolHiddenId'), + ]; + + return { externalTools, availableSchoolExternalTools }; + }; + + it('should filter out hidden external tools', () => { + const { externalTools, availableSchoolExternalTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForAvailableExternalTools( + externalTools, + availableSchoolExternalTools + ); + + expect(result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.externalTool.isHidden)).toBe(true); + }); + }); + }); + + describe('filterParametersForScope', () => { + describe('when filtering parameters for scope', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ + id: 'externalToolId', + parameters: [ + customParameterFactory.build({ name: 'schoolParam', scope: CustomParameterScope.SCHOOL }), + customParameterFactory.build({ name: 'contextParam', scope: CustomParameterScope.CONTEXT }), + ], + }); + const scope = CustomParameterScope.CONTEXT; + + return { externalTool, scope }; + }; + + it('should filter parameters for the given scope', () => { + const { externalTool, scope } = setup(); + + service.filterParametersForScope(externalTool, scope); + + expect(externalTool.parameters?.every((parameter: CustomParameter) => parameter.scope === scope)).toBe(true); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts new file mode 100644 index 00000000000..3b5a186d1ba --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EntityId, Page } from '@shared/domain'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { CustomParameterScope } from '../../common/enum'; +import { CustomParameter } from '../../common/domain'; +import { ContextExternalToolTemplateInfo } from '../uc/dto'; + +@Injectable() +export class ExternalToolConfigurationService { + constructor(@Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures) {} + + public filterForAvailableTools(externalTools: Page, toolIdsInUse: EntityId[]): ExternalTool[] { + const visibleTools: ExternalTool[] = externalTools.data.filter((tool: ExternalTool): boolean => !tool.isHidden); + + const availableTools: ExternalTool[] = visibleTools.filter( + (tool: ExternalTool): boolean => !!tool.id && !toolIdsInUse.includes(tool.id) + ); + return availableTools; + } + + public filterForAvailableSchoolExternalTools( + schoolExternalTools: SchoolExternalTool[], + contextExternalToolsInUse: ContextExternalTool[] + ): SchoolExternalTool[] { + const availableSchoolExternalTools: SchoolExternalTool[] = schoolExternalTools.filter( + (schoolExternalTool: SchoolExternalTool): boolean => { + if (this.toolFeatures.contextConfigurationEnabled) { + return true; + } + + const hasContextExternalTool: boolean = contextExternalToolsInUse.some( + (contextExternalTool: ContextExternalTool) => + contextExternalTool.schoolToolRef.schoolToolId === schoolExternalTool.id + ); + + return !hasContextExternalTool; + } + ); + + return availableSchoolExternalTools; + } + + public filterForAvailableExternalTools( + externalTools: ExternalTool[], + availableSchoolExternalTools: SchoolExternalTool[] + ): ContextExternalToolTemplateInfo[] { + const toolsWithSchoolTool: (ContextExternalToolTemplateInfo | null)[] = availableSchoolExternalTools.map( + (schoolExternalTool: SchoolExternalTool) => { + const externalTool: ExternalTool | undefined = externalTools.find( + (tool: ExternalTool) => schoolExternalTool.toolId === tool.id + ); + + if (!externalTool) { + return null; + } + + return { + externalTool, + schoolExternalTool, + }; + } + ); + + const unusedTools: ContextExternalToolTemplateInfo[] = toolsWithSchoolTool.filter( + (toolRef): toolRef is ContextExternalToolTemplateInfo => !!toolRef + ); + const availableTools: ContextExternalToolTemplateInfo[] = unusedTools.filter( + (toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden + ); + + return availableTools; + } + + public filterParametersForScope(externalTool: ExternalTool, scope: CustomParameterScope) { + if (externalTool.parameters) { + externalTool.parameters = externalTool.parameters.filter( + (parameter: CustomParameter) => parameter.scope === scope + ); + } + } +} diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts new file mode 100644 index 00000000000..57acd50122f --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts @@ -0,0 +1,394 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory, externalToolFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { of, throwError } from 'rxjs'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../domain'; +import { ExternalToolLogo } from '../domain/external-tool-logo'; +import { + ExternalToolLogoFetchFailedLoggableException, + ExternalToolLogoFetchedLoggable, + ExternalToolLogoNotFoundLoggableException, + ExternalToolLogoSizeExceededLoggableException, + ExternalToolLogoWrongFileTypeLoggableException, +} from '../loggable'; +import { ExternalToolLogoService } from './external-tool-logo.service'; +import { ExternalToolService } from './external-tool.service'; + +describe('ExternalToolLogoService', () => { + let module: TestingModule; + let service: ExternalToolLogoService; + + let httpService: DeepMocked; + let logger: DeepMocked; + let toolFeatures: IToolFeatures; + let externalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ExternalToolLogoService, + { + provide: HttpService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + maxExternalToolLogoSizeInBytes: 30000, + }, + }, + { + provide: ExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ExternalToolLogoService); + httpService = module.get(HttpService); + logger = module.get(Logger); + toolFeatures = module.get(ToolFeatures); + externalToolService = module.get(ExternalToolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('buildLogoUrl', () => { + describe('when externalTool has no logo', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const logoUrlTemplate = '/v3/tools/external-tools/{id}/logo'; + + return { + externalTool, + logoUrlTemplate, + }; + }; + + it('should return undefined', () => { + const { externalTool, logoUrlTemplate } = setup(); + + const logoUrl = service.buildLogoUrl(logoUrlTemplate, externalTool); + + expect(logoUrl).toBeUndefined(); + }); + }); + + describe('when externalTool has a logo', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + const logoUrlTemplate = '/v3/tools/external-tools/{id}/logo'; + + const baseUrl = toolFeatures.backEndUrl; + const id = externalTool.id as string; + const expected = `${baseUrl}/v3/tools/external-tools/${id}/logo`; + + return { + externalTool, + logoUrlTemplate, + expected, + }; + }; + + it('should return an internal logoUrl', () => { + const { externalTool, logoUrlTemplate, expected } = setup(); + + const logoUrl = service.buildLogoUrl(logoUrlTemplate, externalTool); + + expect(logoUrl).toEqual(expected); + }); + }); + }); + + describe('validateLogoSize', () => { + describe('when external tool has a given base64 logo', () => { + describe('when size is exceeded', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); + toolFeatures.maxExternalToolLogoSizeInBytes = 1; + + return { externalTool }; + }; + + it('should throw an error', () => { + const { externalTool } = setup(); + + const func = () => service.validateLogoSize(externalTool); + + expect(func).toThrow(ExternalToolLogoSizeExceededLoggableException); + }); + }); + + describe('when size is not exceeded', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); + toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + + return { externalTool }; + }; + + it('should not throw an error', () => { + const { externalTool } = setup(); + + const func = () => service.validateLogoSize(externalTool); + + expect(func).not.toThrow(); + }); + }); + }); + + describe('when external tool has no given logo', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build(); + + return { externalTool }; + }; + + it('should not throw an error', () => { + const { externalTool } = setup(); + + const func = () => service.validateLogoSize(externalTool); + + expect(func).not.toThrow(); + }); + }); + }); + + describe('fetchLogo', () => { + describe('when tool has no logo url', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ logoUrl: undefined }); + + return { + externalTool, + }; + }; + + it('should not fetch the logo', async () => { + const { externalTool } = setup(); + + const logo = await service.fetchLogo(externalTool); + + expect(logo).toBeUndefined(); + }); + }); + + describe('when tool has a logo url', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + const base64Logo = externalTool.logo as string; + const logoBuffer: Buffer = Buffer.from(base64Logo, 'base64'); + + httpService.get.mockReturnValue( + of( + axiosResponseFactory.build({ + data: logoBuffer, + status: HttpStatus.OK, + statusText: 'OK', + }) + ) + ); + + const logoUrl = 'https://logo.com/'; + + return { + externalTool, + base64Logo, + logoUrl, + }; + }; + + it('should fetch logo', async () => { + const { externalTool, logoUrl } = setup(); + + await service.fetchLogo(externalTool); + + expect(httpService.get).toHaveBeenCalledWith(logoUrl, { responseType: 'arraybuffer' }); + }); + + it('should log the fetched url', async () => { + const { externalTool, logoUrl } = setup(); + + await service.fetchLogo(externalTool); + + expect(logger.info).toHaveBeenCalledWith(new ExternalToolLogoFetchedLoggable(logoUrl)); + }); + + it('should return base64 encoded logo', async () => { + const { externalTool, base64Logo } = setup(); + + const logo: string | undefined = await service.fetchLogo(externalTool); + + expect(logo).toBe(base64Logo); + }); + }); + + describe('when error occurs on fetching logo because of an http exception', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + httpService.get.mockReturnValue( + throwError(() => new HttpException('Failed to fetch logo', HttpStatus.NOT_FOUND)) + ); + + return { + externalTool, + }; + }; + + it('should throw error', async () => { + const { externalTool } = setup(); + + const func = () => service.fetchLogo(externalTool); + + await expect(func()).rejects.toEqual( + new ExternalToolLogoFetchFailedLoggableException(externalTool.logoUrl as string, HttpStatus.NOT_FOUND) + ); + }); + }); + + describe('when error occurs on fetching logo because of an wrong file type', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + httpService.get.mockReturnValue(throwError(() => new ExternalToolLogoWrongFileTypeLoggableException())); + + return { + externalTool, + }; + }; + + it('should throw error', async () => { + const { externalTool } = setup(); + + const func = () => service.fetchLogo(externalTool); + + await expect(func()).rejects.toEqual(new ExternalToolLogoWrongFileTypeLoggableException()); + }); + }); + + describe('when error occurs on fetching logo because of another error', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + httpService.get.mockReturnValue(throwError(() => new Error('Failed to fetch logo'))); + + return { + externalTool, + }; + }; + + it('should throw error', async () => { + const { externalTool } = setup(); + + const func = () => service.fetchLogo(externalTool); + + await expect(func()).rejects.toEqual( + new ExternalToolLogoFetchFailedLoggableException(externalTool.logoUrl as string) + ); + }); + }); + + describe('when error occurs on fetching logo because of another error', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + httpService.get.mockReturnValue(throwError(() => new Error('Failed to fetch logo'))); + + return { + externalTool, + }; + }; + + it('should throw error', async () => { + const { externalTool } = setup(); + + const func = () => service.fetchLogo(externalTool); + + await expect(func()).rejects.toThrow(ExternalToolLogoFetchFailedLoggableException); + }); + }); + }); + + describe('getExternalToolBinaryLogo', () => { + describe('when logoBase64 is available', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + + externalToolService.findExternalToolById.mockResolvedValue(externalTool); + + return { + externalToolId: externalTool.id as string, + base64logo: externalTool.logo as string, + }; + }; + + it('should return ExternalToolLogo with proper properties', async () => { + const { externalToolId, base64logo } = setup(); + + const result: ExternalToolLogo = await service.getExternalToolBinaryLogo(externalToolId); + + expect(result).toEqual( + new ExternalToolLogo({ + contentType: 'image/png', + logo: Buffer.from(base64logo, 'base64'), + }) + ); + }); + }); + + describe('when logo has the wrong file type', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ logo: 'notAValidBase64File' }); + + externalToolService.findExternalToolById.mockResolvedValue(externalTool); + + return { + externalToolId: externalTool.id as string, + }; + }; + + it('should throw an ExternalToolLogoWrongFileTypeLoggableException', async () => { + const { externalToolId } = setup(); + + const result: Promise = service.getExternalToolBinaryLogo(externalToolId); + + await expect(result).rejects.toThrow(ExternalToolLogoWrongFileTypeLoggableException); + }); + }); + + describe('when logoBase64 is not available', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + externalToolService.findExternalToolById.mockResolvedValue(externalTool); + + return { + externalToolId: externalTool.id as string, + }; + }; + + it('should throw ExternalToolLogoNotFoundLoggableException', async () => { + const { externalToolId } = setup(); + + const func = async () => service.getExternalToolBinaryLogo(externalToolId); + + await expect(func).rejects.toThrow(ExternalToolLogoNotFoundLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts new file mode 100644 index 00000000000..f2518e65a3a --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts @@ -0,0 +1,126 @@ +import { HttpException, Inject } from '@nestjs/common'; +import { AxiosResponse } from 'axios'; +import { lastValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import { Logger } from '@src/core/logger'; +import { EntityId } from '@shared/domain'; +import { ExternalTool } from '../domain'; +import { + ExternalToolLogoFetchedLoggable, + ExternalToolLogoNotFoundLoggableException, + ExternalToolLogoSizeExceededLoggableException, + ExternalToolLogoWrongFileTypeLoggableException, + ExternalToolLogoFetchFailedLoggableException, +} from '../loggable'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalToolLogo } from '../domain/external-tool-logo'; +import { ExternalToolService } from './external-tool.service'; + +const contentTypeDetector: Record = { + ffd8ffe0: 'image/jpeg', + ffd8ffe1: 'image/jpeg', + '89504e47': 'image/png', + '47494638': 'image/gif', +}; + +export class ExternalToolLogoService { + constructor( + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly logger: Logger, + private readonly httpService: HttpService, + private readonly externalToolService: ExternalToolService + ) {} + + buildLogoUrl(template: string, externalTool: ExternalTool): string | undefined { + const { logo, id } = externalTool; + const backendUrl = this.toolFeatures.backEndUrl; + + if (logo) { + const filledTemplate = template.replace(/\{id\}/g, id || ''); + return `${backendUrl}${filledTemplate}`; + } + + return undefined; + } + + validateLogoSize(externalTool: Partial): void { + if (!externalTool.logo) { + return; + } + + const buffer: Buffer = Buffer.from(externalTool.logo, 'base64'); + + if (buffer.length > this.toolFeatures.maxExternalToolLogoSizeInBytes) { + throw new ExternalToolLogoSizeExceededLoggableException( + externalTool.id, + this.toolFeatures.maxExternalToolLogoSizeInBytes + ); + } + } + + async fetchLogo(externalTool: Partial): Promise { + if (externalTool.logoUrl) { + const base64Logo: string = await this.fetchBase64Logo(externalTool.logoUrl); + + if (base64Logo) { + return base64Logo; + } + } + + return undefined; + } + + private async fetchBase64Logo(logoUrl: string): Promise { + try { + const response: AxiosResponse = await lastValueFrom( + this.httpService.get(logoUrl, { responseType: 'arraybuffer' }) + ); + this.logger.info(new ExternalToolLogoFetchedLoggable(logoUrl)); + + const buffer: Buffer = Buffer.from(response.data); + this.detectContentTypeOrThrow(buffer); + + const logoBase64: string = buffer.toString('base64'); + + return logoBase64; + } catch (error) { + if (error instanceof ExternalToolLogoWrongFileTypeLoggableException) { + throw new ExternalToolLogoWrongFileTypeLoggableException(); + } else if (error instanceof HttpException) { + throw new ExternalToolLogoFetchFailedLoggableException(logoUrl, error.getStatus()); + } else { + throw new ExternalToolLogoFetchFailedLoggableException(logoUrl); + } + } + } + + async getExternalToolBinaryLogo(toolId: EntityId): Promise { + const tool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + + if (!tool.logo) { + throw new ExternalToolLogoNotFoundLoggableException(toolId); + } + + const logoBinaryData: Buffer = Buffer.from(tool.logo, 'base64'); + + const externalToolLogo: ExternalToolLogo = new ExternalToolLogo({ + contentType: this.detectContentTypeOrThrow(logoBinaryData), + logo: logoBinaryData, + }); + + return externalToolLogo; + } + + private detectContentTypeOrThrow(imageBuffer: Buffer): string { + const imageSignature: string = imageBuffer.toString('hex', 0, 4); + + const contentType: string | ExternalToolLogoWrongFileTypeLoggableException = + contentTypeDetector[imageSignature] || new ExternalToolLogoWrongFileTypeLoggableException(); + + if (contentType instanceof ExternalToolLogoWrongFileTypeLoggableException) { + throw new ExternalToolLogoWrongFileTypeLoggableException(); + } + + return contentType; + } +} diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts index eefea58ee9e..b982f4cbf0a 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts @@ -1,20 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; -import { CustomParameterScope, CustomParameterType } from '@shared/domain'; -import { CustomParameterDO, ExternalToolDO } from '@shared/domain/domainobject/tool'; import { - customParameterDOFactory, - externalToolDOFactory, + customParameterFactory, + externalToolFactory, } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; -import { ExternalToolService } from './index'; +import { CustomParameter } from '../../common/domain'; +import { CustomParameterScope, CustomParameterType } from '../../common/enum'; +import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../domain'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; +import { ExternalToolService } from './index'; describe('ExternalToolParameterValidationService', () => { let module: TestingModule; let service: ExternalToolParameterValidationService; let externalToolService: DeepMocked; + let commonToolValidationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -24,11 +27,18 @@ describe('ExternalToolParameterValidationService', () => { provide: ExternalToolService, useValue: createMock(), }, + { + provide: CommonToolValidationService, + useValue: createMock(), + }, ], }).compile(); service = module.get(ExternalToolParameterValidationService); externalToolService = module.get(ExternalToolService); + commonToolValidationService = module.get(CommonToolValidationService); + + commonToolValidationService.isValueValidForType.mockReturnValue(true); }); afterAll(async () => { @@ -39,15 +49,15 @@ describe('ExternalToolParameterValidationService', () => { jest.clearAllMocks(); }); - describe('validateCommon is called', () => { + describe('validateCommon', () => { describe('when tool is valid', () => { it('should return without exception', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { default: 'test', regex: '[t]', regexComment: 'testComment' }) .buildWithId(); - externalToolService.findExternalToolByName.mockResolvedValue(externalToolDO); + externalToolService.findExternalToolByName.mockResolvedValue(externalTool); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).resolves.not.toThrow(); }); @@ -55,27 +65,23 @@ describe('ExternalToolParameterValidationService', () => { describe('when checking if tool name is unique', () => { it('should throw an exception when name already exists', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ name: 'sameName' }); - const existingExternalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId({ name: 'sameName' }); + const externalTool: ExternalTool = externalToolFactory.build({ name: 'sameName' }); + const existingExternalToolDO: ExternalTool = externalToolFactory.buildWithId({ name: 'sameName' }); externalToolService.findExternalToolByName.mockResolvedValue(existingExternalToolDO); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( - new ValidationError(`tool_name_duplicate: The tool name "${externalToolDO.name}" is already used.`) + new ValidationError(`tool_name_duplicate: The tool name "${externalTool.name}" is already used.`) ); }); it('should return when tool name is undefined', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ name: undefined, - parameters: [ - customParameterDOFactory.build({ name: 'sameKey', scope: CustomParameterScope.SCHOOL }), - customParameterDOFactory.build({ name: 'notSameKey', scope: CustomParameterScope.SCHOOL }), - ], }); - const func = () => service.validateCommon(externalToolDO); + const func = () => service.validateCommon(externalTool); await expect(func()).resolves.not.toThrow(); }); @@ -83,54 +89,52 @@ describe('ExternalToolParameterValidationService', () => { describe('when there is an empty parameter name', () => { it('should throw ValidationError', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ - parameters: [customParameterDOFactory.build({ name: '' })], + const externalTool: ExternalTool = externalToolFactory.build({ + parameters: [customParameterFactory.build({ name: '' })], }); externalToolService.findExternalToolByName.mockResolvedValue(null); - const func = () => service.validateCommon(externalToolDO); + const func = () => service.validateCommon(externalTool); await expect(func()).rejects.toThrow( - new ValidationError( - `tool_param_name: The tool ${externalToolDO.name} is missing at least one custom parameter name.` - ) + new ValidationError(`tool_param_name: A custom parameter is missing a name.`) ); }); }); describe('when there are duplicate attributes', () => { it('should fail for two equal parameters', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [ - customParameterDOFactory.build({ name: 'paramEqual' }), - customParameterDOFactory.build({ name: 'paramEqual' }), + customParameterFactory.build({ name: 'paramEqual' }), + customParameterFactory.build({ name: 'paramEqual' }), ], }); externalToolService.findExternalToolByName.mockResolvedValue(null); - const func = () => service.validateCommon(externalToolDO); + const func = () => service.validateCommon(externalTool); await expect(func()).rejects.toThrow( new ValidationError( - `tool_param_duplicate: The tool ${externalToolDO.name} contains multiple of the same custom parameters.` + `tool_param_duplicate: The tool ${externalTool.name} contains multiple of the same custom parameters.` ) ); }); it('should fail for names that only differ in capitalisation', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [ - customParameterDOFactory.build({ name: 'param1CaseSensitive' }), - customParameterDOFactory.build({ name: 'Param1casesensitive' }), + customParameterFactory.build({ name: 'param1CaseSensitive' }), + customParameterFactory.build({ name: 'Param1casesensitive' }), ], }); externalToolService.findExternalToolByName.mockResolvedValue(null); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_param_duplicate: The tool ${externalToolDO.name} contains multiple of the same custom parameters.` + `tool_param_duplicate: The tool ${externalTool.name} contains multiple of the same custom parameters.` ) ); }); @@ -138,14 +142,18 @@ describe('ExternalToolParameterValidationService', () => { describe('when regex is invalid', () => { it('throw when external tools has a faulty regular expression', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.withCustomParameters(1, { regex: '[' }).build(); + const externalTool: ExternalTool = externalToolFactory + .withCustomParameters(1, { regex: '[', regexComment: 'not a regex' }) + .build(); externalToolService.findExternalToolByName.mockResolvedValue(null); - const func = () => service.validateCommon(externalToolDO); + const func = () => service.validateCommon(externalTool); await expect(func()).rejects.toThrow( new ValidationError( - `tool_param_regex_invalid: A custom Parameter of the tool ${externalToolDO.name} has wrong regex attribute.` + `tool_param_regex_invalid: The custom Parameter "${ + externalTool.parameters?.[0].name ?? '' + }" has an invalid regex.` ) ); }); @@ -153,12 +161,12 @@ describe('ExternalToolParameterValidationService', () => { describe('when default value does not match regex', () => { it('should throw', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { default: 'es', regex: '[t]', regexComment: 'mockComment' }) .buildWithId(); externalToolService.findExternalToolByName.mockResolvedValue(null); - const func = () => service.validateCommon(externalToolDO); + const func = () => service.validateCommon(externalTool); await expect(func()).rejects.toThrow('tool_param_default_regex:'); }); @@ -166,17 +174,17 @@ describe('ExternalToolParameterValidationService', () => { describe('when regex is set but regex comment is missing', () => { it('should throw exception', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { regex: '.', scope: CustomParameterScope.SCHOOL }) .build(); externalToolService.findExternalToolByName.mockResolvedValue(null); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_param_regexComment: The "${ - (externalToolDO.parameters as CustomParameterDO[])[0].name + `tool_param_regexComment: The custom parameter "${ + externalTool.parameters?.[0].name ?? '' }" parameter is missing a regex comment.` ) ); @@ -186,7 +194,7 @@ describe('ExternalToolParameterValidationService', () => { describe('when parameters has a parameter with scope global', () => { describe('when parameter has a default value', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { scope: CustomParameterScope.GLOBAL, default: 'defaultValue', @@ -196,22 +204,22 @@ describe('ExternalToolParameterValidationService', () => { externalToolService.findExternalToolByName.mockResolvedValue(null); return { - externalToolDO, + externalTool, }; }; it('should pass', async () => { - const { externalToolDO } = setup(); + const { externalTool } = setup(); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).resolves.not.toThrow(); }); }); - describe('when defaultValue is empty', () => { + describe('when defaultValue is undefined', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { scope: CustomParameterScope.GLOBAL, default: undefined, @@ -221,28 +229,28 @@ describe('ExternalToolParameterValidationService', () => { externalToolService.findExternalToolByName.mockResolvedValue(null); return { - externalToolDO, + externalTool, }; }; it('should throw an exception', async () => { - const { externalToolDO } = setup(); + const { externalTool } = setup(); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_param_default_required: The "${ - (externalToolDO.parameters as CustomParameterDO[])[0].name + `tool_param_default_required: The custom parameter "${ + externalTool.parameters?.[0].name ?? '' }" is a global parameter and requires a default value.` ) ); }); }); - describe('when the defaultValue is undefined', () => { + describe('when the defaultValue is empty', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { scope: CustomParameterScope.GLOBAL, default: '', @@ -252,19 +260,19 @@ describe('ExternalToolParameterValidationService', () => { externalToolService.findExternalToolByName.mockResolvedValue(null); return { - externalToolDO, + externalTool, }; }; it('should throw an exception', async () => { - const { externalToolDO } = setup(); + const { externalTool } = setup(); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_param_default_required: The "${ - (externalToolDO.parameters as CustomParameterDO[])[0].name + `tool_param_default_required: The custom parameter "${ + externalTool.parameters?.[0].name ?? '' }" is a global parameter and requires a default value.` ) ); @@ -273,7 +281,7 @@ describe('ExternalToolParameterValidationService', () => { describe('when the type is an auto type', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withCustomParameters(1, { scope: CustomParameterScope.GLOBAL, type: CustomParameterType.AUTO_CONTEXTID, @@ -284,14 +292,14 @@ describe('ExternalToolParameterValidationService', () => { externalToolService.findExternalToolByName.mockResolvedValue(null); return { - externalToolDO, + externalTool, }; }; it('should pass without a default', async () => { - const { externalToolDO } = setup(); + const { externalTool } = setup(); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).resolves.not.toThrow(); }); @@ -300,29 +308,56 @@ describe('ExternalToolParameterValidationService', () => { describe('when a auto parameter is not in scope global', () => { const setup = () => { - const parameter: CustomParameterDO = customParameterDOFactory.build({ + const parameter: CustomParameter = customParameterFactory.build({ type: CustomParameterType.AUTO_SCHOOLID, scope: CustomParameterScope.SCHOOL, }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ parameters: [parameter] }); + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [parameter] }); externalToolService.findExternalToolByName.mockResolvedValue(null); return { - externalToolDO, + externalTool, + parameter, + }; + }; + + it('should throw exception', async () => { + const { externalTool, parameter } = setup(); + + const result: Promise = service.validateCommon(externalTool); + + await expect(result).rejects.toThrow( + new ValidationError( + `tool_param_auto_requires_global: The custom parameter "${parameter.name}" with type "${parameter.type}" must have the scope "global", since it is automatically filled.` + ) + ); + }); + }); + + describe('when parameter has wrong type as default', () => { + const setup = () => { + const parameter = customParameterFactory.buildWithId({ default: 'test', type: CustomParameterType.NUMBER }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ parameters: [parameter] }); + + externalToolService.findExternalToolByName.mockResolvedValue(externalTool); + commonToolValidationService.isValueValidForType.mockReturnValue(false); + + return { + externalTool, parameter, }; }; it('should throw exception', async () => { - const { externalToolDO, parameter } = setup(); + const { externalTool, parameter } = setup(); - const result: Promise = service.validateCommon(externalToolDO); + const result: Promise = service.validateCommon(externalTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_param_auto_requires_global: The "${parameter.name}" with type "${parameter.type}" must have the scope "global", since it is automatically filled.` + `tool_param_type_mismatch: The default value of the custom parameter "${parameter.name}" should be of type "${parameter.type}".` ) ); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts index cea9e02e987..87690959305 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts @@ -1,80 +1,89 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { autoParameters, CustomParameterScope } from '@shared/domain'; -import { CustomParameterDO, ExternalToolDO } from '@shared/domain/domainobject/tool'; +import { CustomParameter } from '../../common/domain'; +import { autoParameters, CustomParameterScope } from '../../common/enum'; +import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../domain'; import { ExternalToolService } from './external-tool.service'; @Injectable() export class ExternalToolParameterValidationService { - constructor(private readonly externalToolService: ExternalToolService) {} - - async validateCommon(externalToolDO: ExternalToolDO | Partial): Promise { - if (!(await this.isNameUnique(externalToolDO))) { - throw new ValidationError(`tool_name_duplicate: The tool name "${externalToolDO.name || ''}" is already used.`); + constructor( + private readonly externalToolService: ExternalToolService, + private readonly commonToolValidationService: CommonToolValidationService + ) {} + + async validateCommon(externalTool: ExternalTool | Partial): Promise { + if (!(await this.isNameUnique(externalTool))) { + throw new ValidationError(`tool_name_duplicate: The tool name "${externalTool.name || ''}" is already used.`); } - if (externalToolDO.parameters) { - if (this.isCustomParameterNameEmpty(externalToolDO.parameters)) { - throw new ValidationError( - `tool_param_name: The tool ${externalToolDO.name || ''} is missing at least one custom parameter name.` - ); - } - if (this.hasDuplicateAttributes(externalToolDO.parameters)) { - throw new ValidationError( - `tool_param_duplicate: The tool ${externalToolDO.name || ''} contains multiple of the same custom parameters.` - ); - } - if (!this.validateByRegex(externalToolDO.parameters)) { - throw new ValidationError( - `tool_param_regex_invalid: A custom Parameter of the tool ${ - externalToolDO.name || '' - } has wrong regex attribute.` - ); - } - if (!this.validateDefaultValue(externalToolDO.parameters)) { + if (externalTool.parameters) { + if (this.hasDuplicateAttributes(externalTool.parameters)) { throw new ValidationError( - `tool_param_default_regex: The default value of a custom parameter of the tool: ${ - externalToolDO.name || '' - } does not match its regex` + `tool_param_duplicate: The tool ${externalTool.name || ''} contains multiple of the same custom parameters.` ); } - externalToolDO.parameters.forEach((param: CustomParameterDO) => { + + externalTool.parameters.forEach((param: CustomParameter) => { + if (this.isCustomParameterNameEmpty(param)) { + throw new ValidationError(`tool_param_name: A custom parameter is missing a name.`); + } + if (!this.isGlobalParameterValid(param)) { throw new ValidationError( - `tool_param_default_required: The "${param.name}" is a global parameter and requires a default value.` + `tool_param_default_required: The custom parameter "${param.name}" is a global parameter and requires a default value.` ); } + if (!this.isAutoParameterGlobal(param)) { throw new ValidationError( - `tool_param_auto_requires_global: The "${param.name}" with type "${param.type}" must have the scope "global", since it is automatically filled.` + `tool_param_auto_requires_global: The custom parameter "${param.name}" with type "${param.type}" must have the scope "global", since it is automatically filled.` ); } + if (!this.isRegexCommentMandatoryAndFilled(param)) { throw new ValidationError( - `tool_param_regexComment: The "${param.name}" parameter is missing a regex comment.` + `tool_param_regexComment: The custom parameter "${param.name}" parameter is missing a regex comment.` + ); + } + + if (!this.isRegexValid(param)) { + throw new ValidationError( + `tool_param_regex_invalid: The custom Parameter "${param.name}" has an invalid regex.` + ); + } + + if (!this.isDefaultValueOfValidType(param)) { + throw new ValidationError( + `tool_param_type_mismatch: The default value of the custom parameter "${param.name}" should be of type "${param.type}".` + ); + } + + if (!this.isDefaultValueOfValidRegex(param)) { + throw new ValidationError( + `tool_param_default_regex: The default value of a the custom parameter "${param.name}" does not match its regex.` ); } }); } } - private isCustomParameterNameEmpty(customParameters: CustomParameterDO[]): boolean { - const isEmpty = customParameters.some((param: CustomParameterDO) => !param.name); - - return isEmpty; + private isCustomParameterNameEmpty(param: CustomParameter): boolean { + return !param.name || !param.displayName; } - private async isNameUnique(externalToolDO: ExternalToolDO | Partial): Promise { - if (!externalToolDO.name) { + private async isNameUnique(externalTool: ExternalTool | Partial): Promise { + if (!externalTool.name) { return true; } - const duplicate: ExternalToolDO | null = await this.externalToolService.findExternalToolByName(externalToolDO.name); + const duplicate: ExternalTool | null = await this.externalToolService.findExternalToolByName(externalTool.name); - return duplicate == null || duplicate.id === externalToolDO.id; + return duplicate == null || duplicate.id === externalTool.id; } - private hasDuplicateAttributes(customParameter: CustomParameterDO[]): boolean { + private hasDuplicateAttributes(customParameter: CustomParameter[]): boolean { return customParameter.some((item, itemIndex) => customParameter.some( (other, otherIndex) => @@ -83,34 +92,40 @@ export class ExternalToolParameterValidationService { ); } - private validateByRegex(customParameter: CustomParameterDO[]): boolean { - return customParameter.every((param: CustomParameterDO) => { - if (param.regex) { - try { - // eslint-disable-next-line no-new - new RegExp(param.regex); - } catch (e) { - return false; - } + private isRegexValid(param: CustomParameter): boolean { + if (param.regex) { + try { + // eslint-disable-next-line no-new + new RegExp(param.regex); + } catch (e) { + return false; } - return true; - }); + } + + return true; } - private validateDefaultValue(customParameter: CustomParameterDO[]): boolean { - const isValid: boolean = customParameter.every((param: CustomParameterDO) => { - if (param.regex && param.default) { - const reg = new RegExp(param.regex); - const match: boolean = reg.test(param.default); - return match; - } - return true; - }); + private isDefaultValueOfValidRegex(param: CustomParameter): boolean { + if (param.regex && param.default) { + const isValid: boolean = new RegExp(param.regex).test(param.default); - return isValid; + return isValid; + } + + return true; } - private isRegexCommentMandatoryAndFilled(customParameter: CustomParameterDO): boolean { + private isDefaultValueOfValidType(param: CustomParameter): boolean { + if (param.default) { + const isValid: boolean = this.commonToolValidationService.isValueValidForType(param.type, param.default); + + return isValid; + } + + return true; + } + + private isRegexCommentMandatoryAndFilled(customParameter: CustomParameter): boolean { if (customParameter.regex && !customParameter.regexComment) { return false; } @@ -118,23 +133,19 @@ export class ExternalToolParameterValidationService { return true; } - private isGlobalParameterValid(customParameter: CustomParameterDO): boolean { + private isGlobalParameterValid(customParameter: CustomParameter): boolean { if (customParameter.scope !== CustomParameterScope.GLOBAL) { return true; } - if (autoParameters.includes(customParameter.type)) { - return true; - } - - if (customParameter.default) { + if (autoParameters.includes(customParameter.type) || customParameter.default) { return true; } return false; } - private isAutoParameterGlobal(customParameter: CustomParameterDO): boolean { + private isAutoParameterGlobal(customParameter: CustomParameter): boolean { if (!autoParameters.includes(customParameter.type)) { return true; } 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 ec133f6601a..53a07c02e30 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,9 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { Oauth2ToolConfigDO } from '@shared/domain/domainobject/tool'; -import { ToolConfigType } from '@shared/domain'; import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; -import { TokenEndpointAuthMethod } from '../../common/interface'; +import { TokenEndpointAuthMethod, ToolConfigType } from '../../common/enum'; +import { Oauth2ToolConfig } from '../domain'; describe('ExternalToolServiceMapper', () => { let module: TestingModule; @@ -24,7 +23,7 @@ describe('ExternalToolServiceMapper', () => { describe('mapDoToProviderOauthClient', () => { it('should map an Oauth2ToolConfigDO to a ProviderOauthClient', () => { const toolName = 'toolName'; - const oauth2Config: Oauth2ToolConfigDO = new Oauth2ToolConfigDO({ + const oauth2Config: Oauth2ToolConfig = new Oauth2ToolConfig({ type: ToolConfigType.OAUTH2, baseUrl: 'baseUrl', clientId: 'clientId', 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 46fc60be670..31ff93db828 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,10 +1,10 @@ import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; -import { Oauth2ToolConfigDO } from '@shared/domain/domainobject/tool'; import { Injectable } from '@nestjs/common'; +import { Oauth2ToolConfig } from '../domain'; @Injectable() export class ExternalToolServiceMapper { - mapDoToProviderOauthClient(name: string, oauth2Config: Oauth2ToolConfigDO): ProviderOauthClient { + mapDoToProviderOauthClient(name: string, oauth2Config: Oauth2ToolConfig): ProviderOauthClient { return { client_name: name, client_id: oauth2Config.clientId, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts index 92f4f6df1e7..8f5f1607df6 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts @@ -1,11 +1,13 @@ -import { externalToolDOFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; -import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ExternalToolDO } from '@shared/domain/domainobject/tool'; +import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; -import { ExternalToolService } from './external-tool.service'; -import { ExternalToolValidationService } from './external-tool-validation.service'; +import { externalToolFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../domain'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; +import { ExternalToolValidationService } from './external-tool-validation.service'; +import { ExternalToolService } from './external-tool.service'; +import { ExternalToolLogoService } from './external-tool-logo.service'; describe('ExternalToolValidationService', () => { let module: TestingModule; @@ -13,6 +15,8 @@ describe('ExternalToolValidationService', () => { let externalToolService: DeepMocked; let commonToolValidationService: DeepMocked; + let toolFeatures: IToolFeatures; + let logoService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +30,24 @@ describe('ExternalToolValidationService', () => { provide: ExternalToolParameterValidationService, useValue: createMock(), }, + { + provide: ToolFeatures, + useValue: { + maxExternalToolLogoSizeInBytes: 30000, + }, + }, + { + provide: ExternalToolLogoService, + useValue: createMock(), + }, ], }).compile(); service = module.get(ExternalToolValidationService); externalToolService = module.get(ExternalToolService); commonToolValidationService = module.get(ExternalToolParameterValidationService); + toolFeatures = module.get(ToolFeatures); + logoService = module.get(ExternalToolLogoService); }); afterAll(async () => { @@ -42,31 +58,33 @@ describe('ExternalToolValidationService', () => { jest.clearAllMocks(); }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); + describe('validateCreate', () => { + describe('when external tool is given', () => { + it('should call the common validation service', async () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); - describe('validateCreate is called', () => { - it('should call the common validation service', async () => { - await service.validateCreate(externalToolDO); + await service.validateCreate(externalTool); - expect(commonToolValidationService.validateCommon).toHaveBeenCalledWith(externalToolDO); + expect(commonToolValidationService.validateCommon).toHaveBeenCalledWith(externalTool); + }); }); describe('when external tool config has oauth config', () => { describe('when client id is unique', () => { describe('when tool with oauth2 config not exists', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); - return { externalOauthToolDO }; + return { externalOauthTool }; }; it('should not find a tool with this client id', async () => { - const { externalOauthToolDO } = setup(); + const { externalOauthTool } = setup(); - const result: Promise = service.validateCreate(externalOauthToolDO); + const result: Promise = service.validateCreate(externalOauthTool); await expect(result).resolves.not.toThrow(); }); @@ -74,18 +92,18 @@ describe('ExternalToolValidationService', () => { describe('when tool with oauth2 config exists', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalOauthToolDO); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalOauthTool); - return { externalOauthToolDO }; + return { externalOauthTool }; }; it('should return without error', async () => { - const { externalOauthToolDO } = setup(); + const { externalOauthTool } = setup(); - const result: Promise = service.validateCreate(externalOauthToolDO); + const result: Promise = service.validateCreate(externalOauthTool); await expect(result).resolves.not.toThrow(); }); @@ -94,28 +112,28 @@ describe('ExternalToolValidationService', () => { describe('when client id already exists', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - const existingExternalOauthToolDO: ExternalToolDO = externalToolDOFactory + const existingExternalOauthToolDO: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(existingExternalOauthToolDO); return { - externalOauthToolDO, + externalOauthTool, }; }; it('should find a tool with this client id', async () => { - const { externalOauthToolDO } = setup(); + const { externalOauthTool } = setup(); - const result: Promise = service.validateCreate(externalOauthToolDO); + const result: Promise = service.validateCreate(externalOauthTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_clientId_duplicate: The Client Id of the tool ${externalOauthToolDO.name} is already used.` + `tool_clientId_duplicate: The Client Id of the tool ${externalOauthTool.name} is already used.` ) ); }); @@ -123,7 +141,7 @@ describe('ExternalToolValidationService', () => { describe('when there is no client secret', () => { const setup = () => { - const externalOauthToolDOWithoutSecret: ExternalToolDO = externalToolDOFactory + const externalOauthToolDOWithoutSecret: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId' }) .buildWithId(); return { externalOauthToolDOWithoutSecret }; @@ -146,7 +164,7 @@ describe('ExternalToolValidationService', () => { describe('when external tool config is lti11Config', () => { describe('when there is no secret', () => { const setup = () => { - const externalLti11ToolDOWithoutSecret: ExternalToolDO = externalToolDOFactory + const externalLti11ToolDOWithoutSecret: ExternalTool = externalToolFactory .withLti11Config({ key: 'lti11Key', secret: undefined }) .buildWithId(); return { externalLti11ToolDOWithoutSecret }; @@ -167,38 +185,65 @@ describe('ExternalToolValidationService', () => { }); }); }); - }); - describe('validateUpdate is called', () => { - beforeEach(() => { - externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); + describe('when external tool has a given base64 logo', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); + toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + + return { externalTool }; + }; + + it('should call externalToolLogoService', async () => { + const { externalTool } = setup(); + + await service.validateCreate(externalTool); + + expect(logoService.validateLogoSize).toHaveBeenCalledWith(externalTool); + }); }); + }); - it('should call the common validation service', async () => { - externalToolDO.id = 'toolId'; + describe('validateUpdate', () => { + describe('when external tool is given', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ id: 'toolId' }); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); - await service.validateUpdate(externalToolDO.id, externalToolDO); + return { + externalTool, + externalToolId: externalTool.id as string, + }; + }; + + it('should call the common validation service', async () => { + const { externalTool, externalToolId } = setup(); - expect(commonToolValidationService.validateCommon).toHaveBeenCalledWith(externalToolDO); + await service.validateUpdate(externalToolId, externalTool); + + expect(commonToolValidationService.validateCommon).toHaveBeenCalledWith(externalTool); + }); }); describe('when checking if parameter id matches toolId', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalOauthToolDO.id = 'toolId'; - externalToolService.findExternalToolById.mockResolvedValue(externalOauthToolDO); + externalOauthTool.id = 'toolId'; + externalToolService.findExternalToolById.mockResolvedValue(externalOauthTool); return { - externalOauthToolDO, - externalOauthToolId: externalOauthToolDO.id, + externalOauthTool, + externalOauthToolId: externalOauthTool.id, }; }; it('should throw an error if not matches', async () => { - const func = () => service.validateUpdate('notMatchToolId', externalToolDO); + const { externalOauthTool } = setup(); + + const func = () => service.validateUpdate('notMatchToolId', externalOauthTool); await expect(func).rejects.toThrow( new ValidationError(`tool_id_mismatch: The tool has no id or it does not match the path parameter.`) @@ -206,9 +251,9 @@ describe('ExternalToolValidationService', () => { }); it('should return without error if matches', async () => { - const { externalOauthToolDO, externalOauthToolId } = setup(); + const { externalOauthTool, externalOauthToolId } = setup(); - const result: Promise = service.validateUpdate(externalOauthToolId, externalOauthToolDO); + const result: Promise = service.validateUpdate(externalOauthToolId, externalOauthTool); await expect(result).resolves.not.toThrow(); }); @@ -217,39 +262,49 @@ describe('ExternalToolValidationService', () => { describe('when external tool config has oauth config', () => { describe('when config type was changed', () => { const setup = () => { - const existingExternalOauthToolDO: ExternalToolDO = externalToolDOFactory + const existingExternalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(existingExternalOauthToolDO); + externalToolService.findExternalToolById.mockResolvedValue(existingExternalOauthTool); + + const newExternalTool: ExternalTool = externalToolFactory.buildWithId(); + + return { + existingExternalOauthTool, + newExternalTool, + newExternalToolId: newExternalTool.id as string, + }; }; it('should throw', async () => { - setup(); + const { newExternalToolId, newExternalTool } = setup(); - const result: Promise = service.validateUpdate(externalToolDO.id as string, externalToolDO); + const result: Promise = service.validateUpdate(newExternalToolId, newExternalTool); await expect(result).rejects.toThrow( - new ValidationError(`tool_type_immutable: The Config Type of the tool ${externalToolDO.name} is immutable.`) + new ValidationError( + `tool_type_immutable: The Config Type of the tool ${newExternalTool.name} is immutable.` + ) ); }); }); describe('when clientId is the same', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(externalOauthToolDO); + externalToolService.findExternalToolById.mockResolvedValue(externalOauthTool); - return { externalOauthToolDO }; + return { externalOauthTool }; }; it('should pass', async () => { - const { externalOauthToolDO } = setup(); + const { externalOauthTool } = setup(); - const result: Promise = service.validateUpdate(externalOauthToolDO.id as string, externalOauthToolDO); + const result: Promise = service.validateUpdate(externalOauthTool.id as string, externalOauthTool); await expect(result).resolves.not.toThrow(); }); @@ -257,27 +312,27 @@ describe('ExternalToolValidationService', () => { describe('when clientID was changed', () => { const setup = () => { - const externalOauthToolDO: ExternalToolDO = externalToolDOFactory + const externalOauthTool: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - const existingExternalOauthToolDOWithDifferentClientId: ExternalToolDO = externalToolDOFactory + const existingExternalOauthToolDOWithDifferentClientId: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'DifferentClientId', clientSecret: 'secret' }) .buildWithId(); externalToolService.findExternalToolById.mockResolvedValue(existingExternalOauthToolDOWithDifferentClientId); return { - externalOauthToolDO, + externalOauthTool, }; }; it('should throw', async () => { - const { externalOauthToolDO } = setup(); + const { externalOauthTool } = setup(); - const result: Promise = service.validateUpdate(externalOauthToolDO.id as string, externalOauthToolDO); + const result: Promise = service.validateUpdate(externalOauthTool.id as string, externalOauthTool); await expect(result).rejects.toThrow( new ValidationError( - `tool_clientId_immutable: The Client Id of the tool ${externalOauthToolDO.name} is immutable.` + `tool_clientId_immutable: The Client Id of the tool ${externalOauthTool.name} is immutable.` ) ); }); @@ -286,7 +341,7 @@ describe('ExternalToolValidationService', () => { describe('when external tool has another config type then oauth', () => { const setup = () => { - const externalLtiToolDO: ExternalToolDO = externalToolDOFactory.withLti11Config().buildWithId(); + const externalLtiToolDO: ExternalTool = externalToolFactory.withLti11Config().buildWithId(); externalLtiToolDO.id = 'toolId'; externalToolService.findExternalToolById.mockResolvedValue(externalLtiToolDO); @@ -305,5 +360,22 @@ describe('ExternalToolValidationService', () => { await expect(result).resolves.not.toThrow(); }); }); + + describe('when external tool has a given base64 logo', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); + toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + + return { externalTool }; + }; + + it('should call externalToolLogoService', async () => { + const { externalTool } = setup(); + + await service.validateCreate(externalTool); + + expect(logoService.validateLogoSize).toHaveBeenCalledWith(externalTool); + }); + }); }); }); 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 e430887d511..90b8307dc7e 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,85 +1,93 @@ -import { Injectable } from '@nestjs/common'; -import { ExternalToolDO } from '@shared/domain/domainobject/tool'; +import { Inject, Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ExternalToolService } from './external-tool.service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../domain'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; +import { ExternalToolService } from './external-tool.service'; +import { ExternalToolLogoService } from './external-tool-logo.service'; @Injectable() export class ExternalToolValidationService { constructor( private readonly externalToolService: ExternalToolService, - private readonly externalToolParameterValidationService: ExternalToolParameterValidationService + private readonly externalToolParameterValidationService: ExternalToolParameterValidationService, + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly externalToolLogoService: ExternalToolLogoService ) {} - async validateCreate(externalToolDO: ExternalToolDO): Promise { - await this.externalToolParameterValidationService.validateCommon(externalToolDO); + async validateCreate(externalTool: ExternalTool): Promise { + await this.externalToolParameterValidationService.validateCommon(externalTool); + + await this.validateOauth2Config(externalTool); + + this.validateLti11Config(externalTool); + + this.externalToolLogoService.validateLogoSize(externalTool); + } + + async validateUpdate(toolId: string, externalTool: Partial): Promise { + if (toolId !== externalTool.id) { + throw new ValidationError(`tool_id_mismatch: The tool has no id or it does not match the path parameter.`); + } + + await this.externalToolParameterValidationService.validateCommon(externalTool); - await this.validateOauth2Config(externalToolDO); + const loadedTool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + if ( + ExternalTool.isOauth2Config(loadedTool.config) && + externalTool.config && + externalTool.config.type !== loadedTool.config.type + ) { + throw new ValidationError( + `tool_type_immutable: The Config Type of the tool ${externalTool.name || ''} is immutable.` + ); + } + + if ( + externalTool.config && + ExternalTool.isOauth2Config(externalTool.config) && + ExternalTool.isOauth2Config(loadedTool.config) && + externalTool.config.clientId !== loadedTool.config.clientId + ) { + throw new ValidationError( + `tool_clientId_immutable: The Client Id of the tool ${externalTool.name || ''} is immutable.` + ); + } - this.validateLti11Config(externalToolDO); + this.externalToolLogoService.validateLogoSize(externalTool); } - private async validateOauth2Config(externalToolDO: ExternalToolDO): Promise { - if (ExternalToolDO.isOauth2Config(externalToolDO.config)) { - if (!externalToolDO.config.clientSecret) { + private async validateOauth2Config(externalTool: ExternalTool): Promise { + if (ExternalTool.isOauth2Config(externalTool.config)) { + if (!externalTool.config.clientSecret) { throw new ValidationError( - `tool_clientSecret_missing: The Client Secret of the tool ${externalToolDO.name || ''} is missing.` + `tool_clientSecret_missing: The Client Secret of the tool ${externalTool.name || ''} is missing.` ); } - if (!(await this.isClientIdUnique(externalToolDO))) { + if (!(await this.isClientIdUnique(externalTool))) { throw new ValidationError( - `tool_clientId_duplicate: The Client Id of the tool ${externalToolDO.name || ''} is already used.` + `tool_clientId_duplicate: The Client Id of the tool ${externalTool.name || ''} is already used.` ); } } } - private validateLti11Config(externalToolDO: ExternalToolDO): void { - if (ExternalToolDO.isLti11Config(externalToolDO.config)) { - if (!externalToolDO.config.secret) { + private validateLti11Config(externalTool: ExternalTool): void { + if (ExternalTool.isLti11Config(externalTool.config)) { + if (!externalTool.config.secret) { throw new ValidationError( - `tool_secret_missing: The secret of the LTI tool ${externalToolDO.name || ''} is missing.` + `tool_secret_missing: The secret of the LTI tool ${externalTool.name || ''} is missing.` ); } } } - private async isClientIdUnique(externalToolDO: ExternalToolDO): Promise { - let duplicate: ExternalToolDO | null = null; - if (ExternalToolDO.isOauth2Config(externalToolDO.config)) { - duplicate = await this.externalToolService.findExternalToolByOAuth2ConfigClientId(externalToolDO.config.clientId); - } - return duplicate == null || duplicate.id === externalToolDO.id; - } - - async validateUpdate(toolId: string, externalToolDO: Partial): Promise { - if (toolId !== externalToolDO.id) { - throw new ValidationError(`tool_id_mismatch: The tool has no id or it does not match the path parameter.`); - } - - await this.externalToolParameterValidationService.validateCommon(externalToolDO); - - const loadedTool: ExternalToolDO = await this.externalToolService.findExternalToolById(toolId); - if ( - ExternalToolDO.isOauth2Config(loadedTool.config) && - externalToolDO.config && - externalToolDO.config.type !== loadedTool.config.type - ) { - throw new ValidationError( - `tool_type_immutable: The Config Type of the tool ${externalToolDO.name || ''} is immutable.` - ); - } - - if ( - externalToolDO.config && - ExternalToolDO.isOauth2Config(externalToolDO.config) && - ExternalToolDO.isOauth2Config(loadedTool.config) && - externalToolDO.config.clientId !== loadedTool.config.clientId - ) { - throw new ValidationError( - `tool_clientId_immutable: The Client Id of the tool ${externalToolDO.name || ''} is immutable.` - ); + private async isClientIdUnique(externalTool: ExternalTool): Promise { + let duplicate: ExternalTool | null = null; + if (ExternalTool.isOauth2Config(externalTool.config)) { + duplicate = await this.externalToolService.findExternalToolByOAuth2ConfigClientId(externalTool.config.clientId); } + return duplicate == null || duplicate.id === externalTool.id; } } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts index c1601953d00..91566609a8f 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts @@ -1,7 +1,8 @@ -import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '@shared/domain'; -import { CustomParameterDO, ExternalToolDO } from '@shared/domain/domainobject/tool'; -import { customParameterDOFactory, externalToolDOFactory } from '@shared/testing/factory/domainobject/tool'; +import { customParameterFactory, externalToolFactory } from '@shared/testing/factory/domainobject/tool'; import { ExternalToolVersionService } from './external-tool-version.service'; +import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '../../common/enum'; +import { CustomParameter } from '../../common/domain'; +import { ExternalTool } from '../domain'; describe('ExternalToolVersionService', () => { let service: ExternalToolVersionService; @@ -11,7 +12,7 @@ describe('ExternalToolVersionService', () => { }); const setup = () => { - const param1: CustomParameterDO = new CustomParameterDO({ + const param1: CustomParameter = new CustomParameter({ name: 'param1', displayName: 'displayName', default: 'defaulValueParam1', @@ -22,26 +23,26 @@ describe('ExternalToolVersionService', () => { scope: CustomParameterScope.GLOBAL, type: CustomParameterType.STRING, }); - const oldTool: ExternalToolDO = externalToolDOFactory + const oldTool: ExternalTool = externalToolFactory .params({ parameters: [param1], version: 1, }) .build(); - const newTool: ExternalToolDO = externalToolDOFactory.build({ ...oldTool, parameters: [{ ...param1 }] }); + const newTool: ExternalTool = externalToolFactory.build({ ...oldTool, parameters: [{ ...param1 }] }); return { oldTool, newTool, param1, - newToolParams: newTool.parameters as CustomParameterDO[], + newToolParams: newTool.parameters as CustomParameter[], }; }; - const expectIncreasement = (newTool: ExternalToolDO) => expect(newTool.version).toEqual(2); - const expectNoIncreasement = (newTool: ExternalToolDO) => expect(newTool.version).toEqual(1); + const expectIncreasement = (newTool: ExternalTool) => expect(newTool.version).toEqual(2); + const expectNoIncreasement = (newTool: ExternalTool) => expect(newTool.version).toEqual(1); - describe('increaseVersionOfNewToolIfNecessary is called', () => { + describe('increaseVersionOfNewToolIfNecessary', () => { describe('when customParameters on old tool is not defined', () => { it('should not increase version', () => { const { oldTool, newTool } = setup(); @@ -64,7 +65,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('compareParameters is called', () => { + describe('compareParameters', () => { describe('when customParameters are the same', () => { it('should not increase version', () => { const { oldTool, newTool } = setup(); @@ -78,7 +79,7 @@ describe('ExternalToolVersionService', () => { describe('when length of customParameters is different', () => { it('should increase version', () => { const { oldTool, newTool } = setup(); - newTool.parameters?.push(customParameterDOFactory.build()); + newTool.parameters?.push(customParameterFactory.build()); service.increaseVersionOfNewToolIfNecessary(oldTool, newTool); @@ -87,11 +88,11 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasNewRequiredParameter is called', () => { + describe('hasNewRequiredParameter', () => { describe('when new required parameter exists', () => { it('should increase version', () => { const { oldTool, newTool } = setup(); - newTool.parameters?.push(customParameterDOFactory.build({ isOptional: false })); + newTool.parameters?.push(customParameterFactory.build({ isOptional: false })); service.increaseVersionOfNewToolIfNecessary(oldTool, newTool); @@ -100,7 +101,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasChangedParameterNames is called', () => { + describe('hasChangedParameterNames', () => { describe('when the name of some customParameter has changed', () => { it('should increase version', () => { const { oldTool, newTool, param1 } = setup(); @@ -115,7 +116,7 @@ describe('ExternalToolVersionService', () => { describe('when a new optional custom parameter added', () => { it('should not increase version', () => { const { oldTool, newTool, newToolParams } = setup(); - const newOptionalParam: CustomParameterDO = customParameterDOFactory.build({ isOptional: true }); + const newOptionalParam: CustomParameter = customParameterFactory.build({ isOptional: true }); newToolParams.push(newOptionalParam); service.increaseVersionOfNewToolIfNecessary(oldTool, newTool); @@ -125,7 +126,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasChangedRequiredParameters is called', () => { + describe('hasChangedRequiredParameters', () => { describe('when one customParameter change from optional to required', () => { it('should increase version', () => { const { oldTool, newTool, param1 } = setup(); @@ -138,7 +139,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasChangedParameterRegex is called', () => { + describe('hasChangedParameterRegex', () => { describe('when one customParameter has a changed regex', () => { it('should increase version', () => { const { oldTool, newTool, param1 } = setup(); @@ -151,7 +152,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasChangedParameterTypes is called', () => { + describe('hasChangedParameterTypes', () => { describe('when one customParameter has a changed type', () => { it('should increase version', () => { const { oldTool, newTool, param1 } = setup(); @@ -164,7 +165,7 @@ describe('ExternalToolVersionService', () => { }); }); - describe('hasChangedParameterScope is called', () => { + describe('hasChangedParameterScope', () => { describe('when one customParameter has a changed scope', () => { it('should increase version', () => { const { oldTool, newTool, param1 } = setup(); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts index 784838025d4..c71e3b49b91 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { CustomParameterDO, ExternalToolDO } from '@shared/domain/domainobject/tool'; +import { ExternalTool } from '../domain'; +import { CustomParameter } from '../../common/domain'; @Injectable() export class ExternalToolVersionService { - increaseVersionOfNewToolIfNecessary(oldTool: ExternalToolDO, newTool: ExternalToolDO): void { + increaseVersionOfNewToolIfNecessary(oldTool: ExternalTool, newTool: ExternalTool): void { if (!oldTool.parameters || !newTool.parameters) { return; } @@ -12,8 +13,8 @@ export class ExternalToolVersionService { } } - private compareParameters(oldParams: CustomParameterDO[], newParams: CustomParameterDO[]): boolean { - const matchingParams: CustomParameterDO[] = oldParams.filter((oldParam) => + private compareParameters(oldParams: CustomParameter[], newParams: CustomParameter[]): boolean { + const matchingParams: CustomParameter[] = oldParams.filter((oldParam) => newParams.some((newParam) => oldParam.name === newParam.name) ); @@ -28,14 +29,14 @@ export class ExternalToolVersionService { return shouldIncrementVersion; } - private hasNewRequiredParameter(oldParams: CustomParameterDO[], newParams: CustomParameterDO[]): boolean { + private hasNewRequiredParameter(oldParams: CustomParameter[], newParams: CustomParameter[]): boolean { const increase = newParams.some( (newParam) => !newParam.isOptional && oldParams.every((oldParam) => oldParam.name !== newParam.name) ); return increase; } - private hasChangedParameterNames(oldParams: CustomParameterDO[], newParams: CustomParameterDO[]): boolean { + private hasChangedParameterNames(oldParams: CustomParameter[], newParams: CustomParameter[]): boolean { const nonOptionalParams = oldParams.filter((parameter) => !parameter.isOptional); const nonOptionalParamNames = nonOptionalParams.map((parameter) => parameter.name); @@ -48,7 +49,7 @@ export class ExternalToolVersionService { return increase; } - private hasChangedRequiredParameters(newParams: CustomParameterDO[], matchingParams: CustomParameterDO[]): boolean { + private hasChangedRequiredParameters(newParams: CustomParameter[], matchingParams: CustomParameter[]): boolean { const increase = matchingParams.some((param) => { const newParam = newParams.find((p) => p.name === param.name); return newParam && param.isOptional !== newParam.isOptional; @@ -56,7 +57,7 @@ export class ExternalToolVersionService { return increase; } - private hasChangedParameterRegex(newParams: CustomParameterDO[], matchingParams: CustomParameterDO[]): boolean { + private hasChangedParameterRegex(newParams: CustomParameter[], matchingParams: CustomParameter[]): boolean { const increase = matchingParams.some((param) => { const newParam = newParams.find((p) => p.name === param.name); return newParam && param.regex !== newParam.regex; @@ -64,7 +65,7 @@ export class ExternalToolVersionService { return increase; } - private hasChangedParameterTypes(newParams: CustomParameterDO[], matchingParams: CustomParameterDO[]): boolean { + private hasChangedParameterTypes(newParams: CustomParameter[], matchingParams: CustomParameter[]): boolean { const increase = matchingParams.some((param) => { const newParam = newParams.find((p) => p.name === param.name); return newParam && param.type !== newParam.type; @@ -72,7 +73,7 @@ export class ExternalToolVersionService { return increase; } - private hasChangedParameterScope(newParams: CustomParameterDO[], matchingParams: CustomParameterDO[]): boolean { + private hasChangedParameterScope(newParams: CustomParameter[], matchingParams: CustomParameter[]): boolean { const increase = matchingParams.some((param) => { const newParam = newParams.find((p) => p.name === param.name); return newParam && param.scope !== newParam.scope; 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 1a9945aa829..d2913e5401a 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 @@ -1,28 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { CustomParameterScope, IFindOptions, Page, SchoolExternalToolDO, SortOrder } from '@shared/domain'; -import { - CustomParameterDO, - ExternalToolDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, -} from '@shared/domain/domainobject/tool'; +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 { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { - customParameterDOFactory, - externalToolDOFactory, - lti11ToolConfigDOFactory, - oauth2ToolConfigDOFactory, + externalToolFactory, + lti11ToolConfigFactory, + oauth2ToolConfigFactory, } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { LegacyLogger } from '@src/core/logger'; import { ExternalToolSearchQuery } from '../../common/interface'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; +import { ExternalToolServiceMapper } from './external-tool-service.mapper'; import { ExternalToolVersionService } from './external-tool-version.service'; import { ExternalToolService } from './external-tool.service'; -import { ExternalToolServiceMapper } from './external-tool-service.mapper'; describe('ExternalToolService', () => { let module: TestingModule; @@ -93,136 +88,142 @@ describe('ExternalToolService', () => { jest.clearAllMocks(); }); - const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.withCustomParameters(1).buildWithId(); - const oauth2ToolConfigDO: Oauth2ToolConfigDO = oauth2ToolConfigDOFactory.withExternalData().build(); - const oauth2ToolConfigDOWithoutExternalData: Oauth2ToolConfigDO = oauth2ToolConfigDOFactory.build(); - const lti11ToolConfigDO: Lti11ToolConfigDO = lti11ToolConfigDOFactory.build(); + const createTools = () => { + const externalTool: ExternalTool = externalToolFactory.withCustomParameters(1).buildWithId(); + const oauth2ToolConfig: Oauth2ToolConfig = oauth2ToolConfigFactory.withExternalData().build(); + const oauth2ToolConfigWithoutExternalData: Oauth2ToolConfig = oauth2ToolConfigFactory.build(); + const lti11ToolConfig: Lti11ToolConfig = lti11ToolConfigFactory.build(); const oauthClient: ProviderOauthClient = { - client_id: oauth2ToolConfigDO.clientId, - scope: oauth2ToolConfigDO.scope, - token_endpoint_auth_method: oauth2ToolConfigDO.tokenEndpointAuthMethod, - redirect_uris: oauth2ToolConfigDO.redirectUris, - frontchannel_logout_uri: oauth2ToolConfigDO.frontchannelLogoutUri, + client_id: oauth2ToolConfig.clientId, + scope: oauth2ToolConfig.scope, + token_endpoint_auth_method: oauth2ToolConfig.tokenEndpointAuthMethod, + redirect_uris: oauth2ToolConfig.redirectUris, + frontchannel_logout_uri: oauth2ToolConfig.frontchannelLogoutUri, }; return { - externalToolDO, - oauth2ToolConfigDO, - lti11ToolConfigDO, - oauth2ToolConfigDOWithoutExternalData, + externalTool, + oauth2ToolConfig, + lti11ToolConfig, + oauth2ToolConfigWithoutExternalData, oauthClient, }; }; - describe('createExternalTool is called', () => { + describe('createExternalTool', () => { describe('when basic config is set', () => { + const setup = () => { + const { externalTool } = createTools(); + externalToolRepo.save.mockResolvedValue(externalTool); + + return { externalTool }; + }; + it('should call the repo to save a tool', async () => { - const { externalToolDO } = setup(); - externalToolRepo.save.mockResolvedValue(externalToolDO); + const { externalTool } = setup(); - await service.createExternalTool(externalToolDO); + await service.createExternalTool(externalTool); - expect(externalToolRepo.save).toHaveBeenCalledWith(externalToolDO); + expect(externalToolRepo.save).toHaveBeenCalledWith(externalTool); }); - it('should save DO', async () => { - const { externalToolDO } = setup(); - externalToolRepo.save.mockResolvedValue(externalToolDO); + it('should save domain object', async () => { + const { externalTool } = setup(); - const result: ExternalToolDO = await service.createExternalTool(externalToolDO); + const result: ExternalTool = await service.createExternalTool(externalTool); - expect(result).toEqual(externalToolDO); + expect(result).toEqual(externalTool); }); }); describe('when oauth2 config is set', () => { - const setupOauth2 = () => { - const { externalToolDO, oauth2ToolConfigDO, oauthClient } = setup(); - externalToolDO.config = oauth2ToolConfigDO; + const setup = () => { + const { externalTool, oauth2ToolConfig, oauthClient } = createTools(); + externalTool.config = oauth2ToolConfig; mapper.mapDoToProviderOauthClient.mockReturnValue(oauthClient); - externalToolRepo.save.mockResolvedValue(externalToolDO); + externalToolRepo.save.mockResolvedValue(externalTool); - return { externalToolDO, oauth2ToolConfigDO, oauthClient }; + return { externalTool, oauth2ToolConfig, oauthClient }; }; + it('should create oauth2 client', async () => { - const { externalToolDO, oauthClient } = setupOauth2(); + const { externalTool, oauthClient } = setup(); - await service.createExternalTool(externalToolDO); + await service.createExternalTool(externalTool); expect(oauthProviderService.createOAuth2Client).toHaveBeenCalledWith(oauthClient); }); it('should call the repo to save a tool', async () => { - const { externalToolDO } = setupOauth2(); + const { externalTool } = setup(); - await service.createExternalTool(externalToolDO); + await service.createExternalTool(externalTool); - expect(externalToolRepo.save).toHaveBeenCalledWith(externalToolDO); + expect(externalToolRepo.save).toHaveBeenCalledWith(externalTool); }); - it('should save DO', async () => { - const { externalToolDO } = setupOauth2(); + it('should save domain object', async () => { + const { externalTool } = setup(); - const result: ExternalToolDO = await service.createExternalTool(externalToolDO); + const result: ExternalTool = await service.createExternalTool(externalTool); - expect(result).toEqual(externalToolDO); + expect(result).toEqual(externalTool); }); }); describe('when lti11 config is set', () => { - const setupLti11 = () => { + const setup = () => { const encryptedSecret = 'encryptedSecret'; - const { externalToolDO, lti11ToolConfigDO } = setup(); - externalToolDO.config = lti11ToolConfigDO; - const lti11ToolConfigDOEncrypted: Lti11ToolConfigDO = { ...lti11ToolConfigDO, secret: encryptedSecret }; - const externalToolDOEncrypted: ExternalToolDO = externalToolDOFactory.build({ - ...externalToolDO, + const { externalTool, lti11ToolConfig } = createTools(); + externalTool.config = lti11ToolConfig; + const lti11ToolConfigDOEncrypted: Lti11ToolConfig = { ...lti11ToolConfig, secret: encryptedSecret }; + const externalToolDOEncrypted: ExternalTool = externalToolFactory.build({ + ...externalTool, config: lti11ToolConfigDOEncrypted, }); encryptionService.encrypt.mockReturnValue(encryptedSecret); externalToolRepo.save.mockResolvedValue(externalToolDOEncrypted); - return { externalToolDO, lti11ToolConfigDO, encryptedSecret, externalToolDOEncrypted }; + return { externalTool, lti11ToolConfig, encryptedSecret, externalToolDOEncrypted }; }; it('should encrypt the secret', async () => { - const { externalToolDO } = setupLti11(); + const { externalTool } = setup(); - await service.createExternalTool(externalToolDO); + await service.createExternalTool(externalTool); expect(encryptionService.encrypt).toHaveBeenCalledWith('secret'); }); it('should call the repo to save a tool', async () => { - const { externalToolDO } = setupLti11(); + const { externalTool } = setup(); - await service.createExternalTool(externalToolDO); + await service.createExternalTool(externalTool); - expect(externalToolRepo.save).toHaveBeenCalledWith(externalToolDO); + expect(externalToolRepo.save).toHaveBeenCalledWith(externalTool); }); it('should save DO', async () => { - const { externalToolDO, externalToolDOEncrypted } = setupLti11(); + const { externalTool, externalToolDOEncrypted } = setup(); - const result: ExternalToolDO = await service.createExternalTool(externalToolDO); + const result: ExternalTool = await service.createExternalTool(externalTool); expect(result).toEqual(externalToolDOEncrypted); }); }); }); - describe('findExternalTools is called', () => { - const setupFind = () => { - const { externalToolDO } = setup(); - const page = new Page([externalToolDO], 1); + describe('findExternalTools', () => { + const createQuery = () => { + const { externalTool } = createTools(); + const page = new Page([externalTool], 1); const query: ExternalToolSearchQuery = { name: 'toolName', }; - const options: IFindOptions = { + const options: IFindOptions = { order: { id: SortOrder.asc, name: SortOrder.asc, @@ -241,96 +242,138 @@ describe('ExternalToolService', () => { }; describe('when pagination, order and scope are set', () => { - it('should get DOs', async () => { - const { query, options, page } = setupFind(); + const setup = () => { + const { query, options, page } = createQuery(); externalToolRepo.find.mockResolvedValue(page); - const result: Page = await service.findExternalTools(query, options); + return { query, options, page }; + }; + + it('should get domain objects', async () => { + const { query, options, page } = setup(); + + const result: Page = await service.findExternalTools(query, options); expect(result).toEqual(page); }); }); describe('when external tool with oauthConfig is set', () => { - it('should get DOs and add external oauth2 data', async () => { - const { query, options, page } = setupFind(); - const { externalToolDO, oauth2ToolConfigDOWithoutExternalData, oauth2ToolConfigDO, oauthClient } = setup(); - oauth2ToolConfigDO.clientSecret = undefined; - externalToolDO.config = oauth2ToolConfigDOWithoutExternalData; - page.data = [externalToolDO]; + const setup = () => { + const { query, options, page } = createQuery(); + const { externalTool, oauth2ToolConfig, oauthClient } = createTools(); + externalTool.config = oauth2ToolConfig; + page.data = [externalTool]; externalToolRepo.find.mockResolvedValue(page); oauthProviderService.getOAuth2Client.mockResolvedValue(oauthClient); - const result: Page = await service.findExternalTools(query, options); + return { query, options, externalTool, oauth2ToolConfig }; + }; + + it('should get domain objects and add external oauth2 data', async () => { + const { query, options, externalTool, oauth2ToolConfig } = setup(); - expect(result).toEqual({ data: [{ ...externalToolDO, config: oauth2ToolConfigDO }], total: 1 }); + const result: Page = await service.findExternalTools(query, options); + + expect(result).toEqual({ data: [{ ...externalTool, config: oauth2ToolConfig }], total: 1 }); }); }); describe('when oauthProvider throws an error', () => { - it('should filter out oauth2tools with unresolved oauthConfig ', async () => { - const { query, options, page } = setupFind(); - const { externalToolDO, oauth2ToolConfigDOWithoutExternalData, oauth2ToolConfigDO, oauthClient } = setup(); - oauth2ToolConfigDO.clientSecret = undefined; - externalToolDO.config = oauth2ToolConfigDOWithoutExternalData; - page.data = [externalToolDO, externalToolDOFactory.withOauth2Config().build()]; + const setup = () => { + const { query, options, page } = createQuery(); + const { externalTool, oauth2ToolConfigWithoutExternalData, oauth2ToolConfig, oauthClient } = createTools(); + oauth2ToolConfig.clientSecret = undefined; + externalTool.config = oauth2ToolConfigWithoutExternalData; + page.data = [externalTool, externalToolFactory.withOauth2Config().build()]; externalToolRepo.find.mockResolvedValue(page); oauthProviderService.getOAuth2Client.mockResolvedValueOnce(oauthClient); oauthProviderService.getOAuth2Client.mockRejectedValue(new Error('some error occurred during fetching data')); - const result: Page = await service.findExternalTools(query, options); + return { + query, + options, + externalTool, + oauth2ToolConfig, + }; + }; - expect(result).toEqual({ data: [{ ...externalToolDO, config: oauth2ToolConfigDO }], total: 1 }); + it('should filter out oauth2tools with unresolved oauthConfig ', async () => { + const { query, options, externalTool, oauth2ToolConfig } = setup(); + + const result: Page = await service.findExternalTools(query, options); + + expect(result).toEqual({ data: [{ ...externalTool, config: oauth2ToolConfig }], total: 1 }); }); }); }); - describe('findExternalToolById is called', () => { + describe('findExternalToolById', () => { describe('when external tool id is set', () => { - it('should get DO', async () => { - const { externalToolDO } = setup(); - externalToolRepo.findById.mockResolvedValue(externalToolDO); + const setup = () => { + const { externalTool } = createTools(); + externalToolRepo.findById.mockResolvedValue(externalTool); + + return { externalTool }; + }; - const result: ExternalToolDO = await service.findExternalToolById('toolId'); + it('should get domain object', async () => { + const { externalTool } = setup(); - expect(result).toEqual(externalToolDO); + const result: ExternalTool = await service.findExternalToolById('toolId'); + + expect(result).toEqual(externalTool); }); }); describe('when external tool with oauthConfig is set', () => { - it('should get DO and add external oauth2 data', async () => { - const { externalToolDO, oauth2ToolConfigDOWithoutExternalData, oauth2ToolConfigDO, oauthClient } = setup(); - oauth2ToolConfigDO.clientSecret = undefined; - externalToolDO.config = oauth2ToolConfigDOWithoutExternalData; - externalToolRepo.findById.mockResolvedValue(externalToolDO); + const setup = () => { + const { externalTool, oauth2ToolConfigWithoutExternalData, oauth2ToolConfig, oauthClient } = createTools(); + oauth2ToolConfig.clientSecret = undefined; + externalTool.config = oauth2ToolConfigWithoutExternalData; + externalToolRepo.findById.mockResolvedValue(externalTool); oauthProviderService.getOAuth2Client.mockResolvedValue(oauthClient); - const result: ExternalToolDO = await service.findExternalToolById('toolId'); + return { externalTool, oauth2ToolConfig }; + }; + + it('should get domain object and add external oauth2 data', async () => { + const { externalTool, oauth2ToolConfig } = setup(); - expect(result).toEqual({ ...externalToolDO, config: oauth2ToolConfigDO }); + const result: ExternalTool = await service.findExternalToolById('toolId'); + + expect(result).toEqual({ ...externalTool, config: oauth2ToolConfig }); }); }); describe('when oauthConfig could not be resolved', () => { - it('should throw UnprocessableEntityException ', async () => { - const { externalToolDO, oauth2ToolConfigDOWithoutExternalData, oauth2ToolConfigDO } = setup(); - oauth2ToolConfigDO.clientSecret = undefined; - externalToolDO.config = oauth2ToolConfigDOWithoutExternalData; - externalToolRepo.findById.mockResolvedValue(externalToolDO); + const setup = () => { + const { externalTool, oauth2ToolConfigWithoutExternalData, oauth2ToolConfig } = createTools(); + oauth2ToolConfig.clientSecret = undefined; + externalTool.config = oauth2ToolConfigWithoutExternalData; + externalToolRepo.findById.mockResolvedValue(externalTool); oauthProviderService.getOAuth2Client.mockRejectedValueOnce( new Error('some error occurred during fetching data') ); + return { externalTool }; + }; + + it('should throw UnprocessableEntityException ', async () => { + const { externalTool } = setup(); + const func = () => service.findExternalToolById('toolId'); - await expect(func()).rejects.toThrow(`Could not resolve oauth2Config of tool ${externalToolDO.name}.`); + await expect(func()).rejects.toThrow(`Could not resolve oauth2Config of tool ${externalTool.name}.`); }); }); }); - describe('deleteExternalTool is called', () => { - const setupDelete = () => { - const schoolExternalToolDO: SchoolExternalToolDO = new SchoolExternalToolDO({ + describe('deleteExternalTool', () => { + const setup = () => { + createTools(); + + const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ id: 'schoolTool1', toolId: 'tool1', schoolId: 'school1', @@ -338,45 +381,39 @@ describe('ExternalToolService', () => { toolVersion: 1, }); - schoolToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalToolDO]); + schoolToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalTool]); - return { schoolExternalToolDO }; + return { schoolExternalTool }; }; describe('when tool id is set', () => { it('should delete all related CourseExternalTools', async () => { - const toolId = 'tool1'; - setup(); - const { schoolExternalToolDO } = setupDelete(); + const { schoolExternalTool } = setup(); - await service.deleteExternalTool(toolId); + await service.deleteExternalTool(schoolExternalTool.toolId); - expect(courseToolRepo.deleteBySchoolExternalToolIds).toHaveBeenCalledWith([schoolExternalToolDO.id]); + expect(courseToolRepo.deleteBySchoolExternalToolIds).toHaveBeenCalledWith([schoolExternalTool.id]); }); it('should delete all related SchoolExternalTools', async () => { - const toolId = 'tool1'; - setup(); - setupDelete(); + const { schoolExternalTool } = setup(); - await service.deleteExternalTool(toolId); + await service.deleteExternalTool(schoolExternalTool.toolId); - expect(schoolToolRepo.deleteByExternalToolId).toHaveBeenCalledWith(toolId); + expect(schoolToolRepo.deleteByExternalToolId).toHaveBeenCalledWith(schoolExternalTool.toolId); }); it('should delete the ExternalTool', async () => { - const toolId = 'tool1'; - setup(); - setupDelete(); + const { schoolExternalTool } = setup(); - await service.deleteExternalTool(toolId); + await service.deleteExternalTool(schoolExternalTool.toolId); - expect(externalToolRepo.deleteById).toHaveBeenCalledWith(toolId); + expect(externalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); }); }); - describe('findExternalToolByName is called', () => { + describe('findExternalToolByName', () => { describe('when name is set', () => { it('should call the externalToolRepo', async () => { const toolName = 'toolName'; @@ -386,28 +423,38 @@ describe('ExternalToolService', () => { expect(externalToolRepo.findByName).toHaveBeenCalledWith(toolName); }); }); + describe('when tool was found', () => { - it('should return externalToolDO ', async () => { - const externalTool: ExternalToolDO = externalToolDOFactory.build(); + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build(); externalToolRepo.findByName.mockResolvedValue(externalTool); + }; + + it('should return externalTool ', async () => { + setup(); - const result: ExternalToolDO | null = await service.findExternalToolByName('toolName'); + const result: ExternalTool | null = await service.findExternalToolByName('toolName'); - expect(result).toBeInstanceOf(ExternalToolDO); + expect(result).toBeInstanceOf(ExternalTool); }); }); + describe('when tool was not found', () => { - it('should return null', async () => { + const setup = () => { externalToolRepo.findByName.mockResolvedValue(null); + }; - const result: ExternalToolDO | null = await service.findExternalToolByName('toolName'); + it('should return null', async () => { + setup(); + + const result: ExternalTool | null = await service.findExternalToolByName('toolName'); expect(result).toBeNull(); }); }); }); - describe('findExternalToolByOAuth2ConfigClientId is called', () => { + describe('findExternalToolByOAuth2ConfigClientId', () => { describe('when oauthClient id is set', () => { it('should call the externalToolRepo', async () => { const clientId = 'clientId'; @@ -417,53 +464,58 @@ describe('ExternalToolService', () => { expect(externalToolRepo.findByOAuth2ConfigClientId).toHaveBeenCalledWith(clientId); }); - it('should return externalToolDO when tool was found', async () => { - const externalTool: ExternalToolDO = externalToolDOFactory.build(); + it('should return externalTool when tool was found', async () => { + const externalTool: ExternalTool = externalToolFactory.build(); externalToolRepo.findByOAuth2ConfigClientId.mockResolvedValue(externalTool); - const result: ExternalToolDO | null = await service.findExternalToolByOAuth2ConfigClientId('clientId'); + const result: ExternalTool | null = await service.findExternalToolByOAuth2ConfigClientId('clientId'); - expect(result).toBeInstanceOf(ExternalToolDO); + expect(result).toBeInstanceOf(ExternalTool); }); }); + describe(' when externalTool was not found', () => { - it('should return null', async () => { + const setup = () => { externalToolRepo.findByOAuth2ConfigClientId.mockResolvedValue(null); + }; - const result: ExternalToolDO | null = await service.findExternalToolByOAuth2ConfigClientId('clientId'); + it('should return null', async () => { + setup(); + + const result: ExternalTool | null = await service.findExternalToolByOAuth2ConfigClientId('clientId'); expect(result).toBeNull(); }); }); }); - describe('updateExternalTool is called', () => { - const setupOauthConfig = () => { - const existingTool: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); - const changedTool: ExternalToolDO = externalToolDOFactory - .withOauth2Config() - .build({ id: existingTool.id, name: 'newName' }); - - const oauthClientId: string = - existingTool.config instanceof Oauth2ToolConfigDO ? existingTool.config.clientId : 'undefined'; - const providerOauthClient: ProviderOauthClient = { - client_id: oauthClientId, - }; - - oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); - mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); + describe('updateExternalTool', () => { + describe('when external tool with oauthConfig is given', () => { + const setup = () => { + const existingTool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); + const changedTool: ExternalTool = externalToolFactory + .withOauth2Config() + .build({ id: existingTool.id, name: 'newName' }); + + const oauthClientId: string = + existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; + const providerOauthClient: ProviderOauthClient = { + client_id: oauthClientId, + }; - return { - existingTool, - changedTool, - providerOauthClient, - oauthClientId, + oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); + mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); + + return { + existingTool, + changedTool, + providerOauthClient, + oauthClientId, + }; }; - }; - describe('when external tool with oauthConfig is given', () => { it('should call externalToolServiceMapper', async () => { - const { changedTool, existingTool } = setupOauthConfig(); + const { changedTool, existingTool } = setup(); await service.updateExternalTool(changedTool, existingTool); @@ -472,8 +524,31 @@ describe('ExternalToolService', () => { }); describe('when oauthClientId is set', () => { + const setup = () => { + const existingTool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); + const changedTool: ExternalTool = externalToolFactory + .withOauth2Config() + .build({ id: existingTool.id, name: 'newName' }); + + const oauthClientId: string = + existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; + const providerOauthClient: ProviderOauthClient = { + client_id: oauthClientId, + }; + + oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); + mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); + + return { + existingTool, + changedTool, + providerOauthClient, + oauthClientId, + }; + }; + it('should call oauthProviderService', async () => { - const { changedTool, oauthClientId, existingTool } = setupOauthConfig(); + const { changedTool, oauthClientId, existingTool } = setup(); await service.updateExternalTool(changedTool, existingTool); @@ -482,8 +557,31 @@ describe('ExternalToolService', () => { }); describe('when oauthClientId is set and providerClient is given', () => { + const setup = () => { + const existingTool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); + const changedTool: ExternalTool = externalToolFactory + .withOauth2Config() + .build({ id: existingTool.id, name: 'newName' }); + + const oauthClientId: string = + existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; + const providerOauthClient: ProviderOauthClient = { + client_id: oauthClientId, + }; + + oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); + mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); + + return { + existingTool, + changedTool, + providerOauthClient, + oauthClientId, + }; + }; + it('should update the oauth2Client', async () => { - const { changedTool, oauthClientId, providerOauthClient, existingTool } = setupOauthConfig(); + const { changedTool, oauthClientId, providerOauthClient, existingTool } = setup(); await service.updateExternalTool(changedTool, existingTool); @@ -492,10 +590,27 @@ describe('ExternalToolService', () => { }); describe('when requested oauth2Client not exists', () => { - it('should throw an error ', async () => { - const { changedTool, providerOauthClient, existingTool } = setupOauthConfig(); - providerOauthClient.client_id = undefined; + const setup = () => { + const existingTool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId(); + const changedTool: ExternalTool = externalToolFactory + .withOauth2Config() + .build({ id: existingTool.id, name: 'newName' }); + + const providerOauthClient: ProviderOauthClient = { + client_id: undefined, + }; + oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); + mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); + + return { + changedTool, + existingTool, + }; + }; + + it('should throw an error ', async () => { + const { changedTool, existingTool } = setup(); const func = () => service.updateExternalTool(changedTool, existingTool); @@ -504,66 +619,65 @@ describe('ExternalToolService', () => { }); describe('when external tool is given', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + return { + externalTool, + }; + }; + it('should save the externalTool', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); + const { externalTool } = setup(); - await service.updateExternalTool(externalToolDO, externalToolDO); + await service.updateExternalTool(externalTool, externalTool); expect(externalToolRepo.save).toHaveBeenCalled(); }); }); - describe('externalToolVersionService is called', () => { - it('should call increaseVersionOfNewToolIfNecessary', async () => { - const tool1: ExternalToolDO = externalToolDOFactory.buildWithId(); - const tool2: ExternalToolDO = externalToolDOFactory.buildWithId(); + describe('externalToolVersionService', () => { + describe('when service', () => { + const setup = () => { + const tool1: ExternalTool = externalToolFactory.buildWithId(); + const tool2: ExternalTool = externalToolFactory.buildWithId(); - await service.updateExternalTool(tool1, tool2); + return { + tool1, + tool2, + }; + }; - expect(versionService.increaseVersionOfNewToolIfNecessary).toHaveBeenCalledWith(tool2, tool1); - }); + it('should call increaseVersionOfNewToolIfNecessary', async () => { + const { tool1, tool2 } = setup(); - it('should increase the version of the externalTool', async () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); - externalToolDO.version = 1; - versionService.increaseVersionOfNewToolIfNecessary.mockImplementation((toolDO: ExternalToolDO) => { - toolDO.version = 2; - return toolDO; - }); - - await service.updateExternalTool(externalToolDO, externalToolDO); + await service.updateExternalTool(tool1, tool2); - expect(externalToolRepo.save).toHaveBeenCalledWith({ ...externalToolDO, version: 2 }); + expect(versionService.increaseVersionOfNewToolIfNecessary).toHaveBeenCalledWith(tool2, tool1); + }); }); - }); - }); - describe('getExternalToolForScope is called', () => { - describe('when scope school is given', () => { - it('should return an external tool with only school scoped custom parameters', async () => { - const schoolParameters: CustomParameterDO[] = customParameterDOFactory.buildList(1, { - scope: CustomParameterScope.SCHOOL, - }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId( - { - parameters: [ - ...schoolParameters, - ...customParameterDOFactory.buildList(1, { scope: CustomParameterScope.CONTEXT }), - ...customParameterDOFactory.buildList(2, { scope: CustomParameterScope.GLOBAL }), - ], - }, - 'toolId' - ); - const expected: ExternalToolDO = externalToolDOFactory.build({ - ...externalToolDO, - parameters: schoolParameters, - }); + describe('when increaseVersionOfNewToolIfNecessary returns a tool with higher version', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + externalTool.version = 1; + versionService.increaseVersionOfNewToolIfNecessary.mockImplementation((toolDO: ExternalTool) => { + toolDO.version = 2; + return toolDO; + }); - externalToolRepo.findById.mockResolvedValue(externalToolDO); + return { + externalTool, + }; + }; - const result: ExternalToolDO = await service.getExternalToolForScope('toolId', CustomParameterScope.SCHOOL); + it('should increase the version of the externalTool', async () => { + const { externalTool } = setup(); - expect(result).toEqual(expected); + await service.updateExternalTool(externalTool, externalTool); + + expect(externalToolRepo.save).toHaveBeenCalledWith({ ...externalTool, version: 2 }); + }); }); }); }); 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 46abf8126c8..2a53f8aae45 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,22 +1,16 @@ import { Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { - CustomParameterDO, - CustomParameterScope, - EntityId, - ExternalToolDO, - IFindOptions, - Oauth2ToolConfigDO, - Page, - SchoolExternalToolDO, -} from '@shared/domain'; +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 { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; -import { ExternalToolSearchQuery, TokenEndpointAuthMethod } from '../../common/interface'; -import { ExternalToolVersionService } from './external-tool-version.service'; +import { TokenEndpointAuthMethod } from '../../common/enum'; +import { ExternalToolSearchQuery } from '../../common/interface'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalTool, Oauth2ToolConfig } from '../domain'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; +import { ExternalToolVersionService } from './external-tool-version.service'; @Injectable() export class ExternalToolService { @@ -27,46 +21,46 @@ export class ExternalToolService { private readonly schoolExternalToolRepo: SchoolExternalToolRepo, private readonly contextExternalToolRepo: ContextExternalToolRepo, @Inject(DefaultEncryptionService) private readonly encryptionService: IEncryptionService, - private readonly logger: LegacyLogger, + private readonly legacyLogger: LegacyLogger, private readonly externalToolVersionService: ExternalToolVersionService ) {} - async createExternalTool(externalToolDO: ExternalToolDO): Promise { - if (ExternalToolDO.isLti11Config(externalToolDO.config) && externalToolDO.config.secret) { - externalToolDO.config.secret = this.encryptionService.encrypt(externalToolDO.config.secret); - } else if (ExternalToolDO.isOauth2Config(externalToolDO.config)) { + async createExternalTool(externalTool: ExternalTool): Promise { + if (ExternalTool.isLti11Config(externalTool.config) && externalTool.config.secret) { + externalTool.config.secret = this.encryptionService.encrypt(externalTool.config.secret); + } else if (ExternalTool.isOauth2Config(externalTool.config)) { const oauthClient: ProviderOauthClient = this.mapper.mapDoToProviderOauthClient( - externalToolDO.name, - externalToolDO.config + externalTool.name, + externalTool.config ); await this.oauthProviderService.createOAuth2Client(oauthClient); } - const created: ExternalToolDO = await this.externalToolRepo.save(externalToolDO); + const created: ExternalTool = await this.externalToolRepo.save(externalTool); return created; } - async updateExternalTool(toUpdate: ExternalToolDO, loadedTool: ExternalToolDO): Promise { + async updateExternalTool(toUpdate: ExternalTool, loadedTool: ExternalTool): Promise { await this.updateOauth2ToolConfig(toUpdate); this.externalToolVersionService.increaseVersionOfNewToolIfNecessary(loadedTool, toUpdate); - const externalTool: ExternalToolDO = await this.externalToolRepo.save(toUpdate); + const externalTool: ExternalTool = await this.externalToolRepo.save(toUpdate); return externalTool; } async findExternalTools( query: ExternalToolSearchQuery, - options?: IFindOptions - ): Promise> { - const tools: Page = await this.externalToolRepo.find(query, options); + options?: IFindOptions + ): Promise> { + const tools: Page = await this.externalToolRepo.find(query, options); - const resolvedTools: (ExternalToolDO | undefined)[] = await Promise.all( - tools.data.map(async (tool: ExternalToolDO): Promise => { - if (ExternalToolDO.isOauth2Config(tool.config)) { + const resolvedTools: (ExternalTool | undefined)[] = await Promise.all( + tools.data.map(async (tool: ExternalTool): Promise => { + if (ExternalTool.isOauth2Config(tool.config)) { try { await this.addExternalOauth2DataToConfig(tool.config); } catch (e) { - this.logger.debug( + this.legacyLogger.debug( `Could not resolve oauth2Config of tool with clientId ${tool.config.clientId}. It will be filtered out.` ); return undefined; @@ -76,18 +70,18 @@ export class ExternalToolService { }) ); - tools.data = resolvedTools.filter((tool) => tool !== undefined) as ExternalToolDO[]; + tools.data = resolvedTools.filter((tool) => tool !== undefined) as ExternalTool[]; return tools; } - async findExternalToolById(id: EntityId): Promise { - const tool: ExternalToolDO = await this.externalToolRepo.findById(id); - if (ExternalToolDO.isOauth2Config(tool.config)) { + async findExternalToolById(id: EntityId): Promise { + const tool: ExternalTool = await this.externalToolRepo.findById(id); + if (ExternalTool.isOauth2Config(tool.config)) { try { await this.addExternalOauth2DataToConfig(tool.config); } catch (e) { - this.logger.debug( + this.legacyLogger.debug( `Could not resolve oauth2Config of tool with clientId ${tool.config.clientId}. It will be filtered out.` ); throw new UnprocessableEntityException(`Could not resolve oauth2Config of tool ${tool.name}.`); @@ -96,20 +90,20 @@ export class ExternalToolService { return tool; } - findExternalToolByName(name: string): Promise { - const externalTool: Promise = this.externalToolRepo.findByName(name); + findExternalToolByName(name: string): Promise { + const externalTool: Promise = this.externalToolRepo.findByName(name); return externalTool; } - findExternalToolByOAuth2ConfigClientId(clientId: string): Promise { - const externalTool: Promise = this.externalToolRepo.findByOAuth2ConfigClientId(clientId); + findExternalToolByOAuth2ConfigClientId(clientId: string): Promise { + const externalTool: Promise = this.externalToolRepo.findByOAuth2ConfigClientId(clientId); return externalTool; } async deleteExternalTool(toolId: EntityId): Promise { - const schoolExternalTools: SchoolExternalToolDO[] = await this.schoolExternalToolRepo.findByExternalToolId(toolId); + const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolRepo.findByExternalToolId(toolId); const schoolExternalToolIds: string[] = schoolExternalTools.map( - (schoolExternalTool: SchoolExternalToolDO): string => + (schoolExternalTool: SchoolExternalTool): string => // We can be sure that the repo returns the id schoolExternalTool.id as string ); @@ -121,18 +115,8 @@ export class ExternalToolService { ]); } - async getExternalToolForScope(externalToolId: EntityId, scope: CustomParameterScope): Promise { - const externalTool: ExternalToolDO = await this.externalToolRepo.findById(externalToolId); - if (externalTool.parameters) { - externalTool.parameters = externalTool.parameters.filter( - (parameter: CustomParameterDO) => parameter.scope === scope - ); - } - return externalTool; - } - - private async updateOauth2ToolConfig(toUpdate: ExternalToolDO) { - if (ExternalToolDO.isOauth2Config(toUpdate.config)) { + private async updateOauth2ToolConfig(toUpdate: ExternalTool) { + if (ExternalTool.isOauth2Config(toUpdate.config)) { const toUpdateOauthClient: ProviderOauthClient = this.mapper.mapDoToProviderOauthClient( toUpdate.name, toUpdate.config @@ -147,7 +131,7 @@ export class ExternalToolService { private async updateOauthClientOrThrow( loadedOauthClient: ProviderOauthClient, toUpdateOauthClient: ProviderOauthClient, - toUpdate: ExternalToolDO + toUpdate: ExternalTool ) { if (loadedOauthClient && loadedOauthClient.client_id) { await this.oauthProviderService.updateOAuth2Client(loadedOauthClient.client_id, toUpdateOauthClient); @@ -156,7 +140,7 @@ export class ExternalToolService { } } - private async addExternalOauth2DataToConfig(config: Oauth2ToolConfigDO) { + private async addExternalOauth2DataToConfig(config: Oauth2ToolConfig) { const oauthClient: ProviderOauthClient = await this.oauthProviderService.getOAuth2Client(config.clientId); config.scope = oauthClient.scope; diff --git a/apps/server/src/modules/tool/external-tool/service/index.ts b/apps/server/src/modules/tool/external-tool/service/index.ts index fb99c3342cc..8cb1df69bc1 100644 --- a/apps/server/src/modules/tool/external-tool/service/index.ts +++ b/apps/server/src/modules/tool/external-tool/service/index.ts @@ -3,3 +3,5 @@ export * from './external-tool-service.mapper'; export * from './external-tool-version.service'; export * from './external-tool-validation.service'; export * from './external-tool-parameter-validation.service'; +export * from './external-tool-configuration.service'; +export * from './external-tool-logo.service'; diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool-configuration.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool-configuration.types.ts index bc964bb0185..bfec90532b5 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool-configuration.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool-configuration.types.ts @@ -1,6 +1,8 @@ -import { ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { ExternalTool } from '../../domain'; -export type AvailableToolsForContext = { - externalTool: ExternalToolDO; - schoolExternalTool: SchoolExternalToolDO; +export type ContextExternalToolTemplateInfo = { + externalTool: ExternalTool; + + schoolExternalTool: SchoolExternalTool; }; diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts index fb26acd2ab8..1c086cb9c96 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts @@ -1,34 +1,32 @@ -import { - BasicToolConfigDO, - CustomParameterDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, -} from '@shared/domain/domainobject/tool'; +import { BasicToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from '../../domain'; +import { CustomParameter } from '../../../common/domain'; type PartialBy = Omit & Partial>; -export type BasicToolConfig = BasicToolConfigDO; +export type BasicToolConfigDto = BasicToolConfig; -export type Lti11ToolConfigCreate = Lti11ToolConfigDO; +export type Lti11ToolConfigCreate = Lti11ToolConfig; -export type Lti11ToolConfigUpdate = PartialBy; +export type Lti11ToolConfigUpdate = PartialBy; -export type Oauth2ToolConfigCreate = Oauth2ToolConfigDO; +export type Oauth2ToolConfigCreate = Oauth2ToolConfig; -export type Oauth2ToolConfigUpdate = PartialBy; +export type Oauth2ToolConfigUpdate = PartialBy; -export type CustomParameter = CustomParameterDO; +export type CustomParameterDto = CustomParameter; -export type ExternalTool = { +export type ExternalToolDto = { name: string; url?: string; + logo?: string; + logoUrl?: string; config: T; - parameters?: CustomParameter[]; + parameters?: CustomParameterDto[]; isHidden: boolean; @@ -37,8 +35,10 @@ export type ExternalTool = { version: number; }; -export type ExternalToolCreate = ExternalTool; +export type ExternalToolCreate = ExternalToolDto; -export type ExternalToolUpdate = ExternalTool & { +export type ExternalToolUpdate = ExternalToolDto< + BasicToolConfigDto | Lti11ToolConfigUpdate | Oauth2ToolConfigUpdate +> & { id: string; }; diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts index 4122a3d0b99..5490f0c546b 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts @@ -1,35 +1,36 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; +import { ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Page, Permission } from '@shared/domain'; import { - ContextExternalToolDO, - CustomParameterScope, - ExternalToolDO, - Page, - Permission, - SchoolExternalToolDO, - User, -} from '@shared/domain'; -import { contextExternalToolDOFactory, setupEntities, userFactory } from '@shared/testing'; -import { externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing/factory/domainobject/tool'; -import { ICurrentUser } from '@src/modules/authentication'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '../../../authorization'; -import { ExternalToolConfigurationUc } from './external-tool-configuration.uc'; -import { ToolContextType } from '../../common/interface'; -import { ExternalToolService } from '../service'; -import { SchoolExternalToolService } from '../../school-external-tool/service/school-external-tool.service'; + contextExternalToolFactory, + customParameterFactory, + externalToolFactory, + schoolExternalToolFactory, + setupEntities, +} from '@shared/testing'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { CustomParameterScope, ToolContextType } from '../../common/enum'; +import { ContextExternalTool } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ExternalTool } from '../domain'; +import { ExternalToolLogoService, ExternalToolService, ExternalToolConfigurationService } from '../service'; +import { ExternalToolConfigurationUc } from './external-tool-configuration.uc'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ExternalToolConfigurationUc', () => { let module: TestingModule; let uc: ExternalToolConfigurationUc; let externalToolService: DeepMocked; - let authorizationService: DeepMocked; + let externalToolConfigurationService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; + let toolPermissionHelper: DeepMocked; + let logoService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -41,10 +42,6 @@ describe('ExternalToolConfigurationUc', () => { provide: ExternalToolService, useValue: createMock(), }, - { - provide: AuthorizationService, - useValue: createMock(), - }, { provide: SchoolExternalToolService, useValue: createMock(), @@ -53,14 +50,28 @@ describe('ExternalToolConfigurationUc', () => { provide: ContextExternalToolService, useValue: createMock(), }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, + { + provide: ExternalToolConfigurationService, + useValue: createMock(), + }, + { + provide: ExternalToolLogoService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(ExternalToolConfigurationUc); externalToolService = module.get(ExternalToolService); - authorizationService = module.get(AuthorizationService); + externalToolConfigurationService = module.get(ExternalToolConfigurationService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); + toolPermissionHelper = module.get(ToolPermissionHelper); + logoService = module.get(ExternalToolLogoService); }); afterEach(() => { @@ -71,349 +82,179 @@ describe('ExternalToolConfigurationUc', () => { await module.close(); }); - describe('getExternalToolForSchool is called', () => { - const setupAuthorization = () => { - const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id, schoolId: user.school.id } as ICurrentUser; - - return { - user, - currentUser, - }; - }; - const setupForSchool = () => { - const externalToolId: string = new ObjectId().toHexString(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(undefined, externalToolId); + describe('getAvailableToolsForSchool', () => { + describe('when checking for the users permission', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.build(); - externalToolService.getExternalToolForScope.mockResolvedValue(externalToolDO); + externalToolService.findExternalTools.mockResolvedValue(new Page([], 0)); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); - return { - externalToolDO, - externalToolId, + return { + tool, + }; }; - }; - - describe('when the user has permission to read an external tool', () => { - it('should successfully check the user permission with the authorization service', async () => { - const { currentUser, user } = setupAuthorization(); - const { externalToolId } = setupForSchool(); - - await uc.getExternalToolForSchool(currentUser.userId, externalToolId, 'schoolId'); - - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.School, - 'schoolId', - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } - ); - }); - it('should call the externalToolService', async () => { - const { currentUser } = setupAuthorization(); - const { externalToolId } = setupForSchool(); + it('should call the toolPermissionHelper with SCHOOL_TOOL_ADMIN permission', async () => { + const { tool } = setup(); - await uc.getExternalToolForSchool(currentUser.userId, externalToolId, 'schoolId'); + await uc.getAvailableToolsForSchool('userId', 'schoolId'); - expect(externalToolService.getExternalToolForScope).toHaveBeenCalledWith( - externalToolId, - CustomParameterScope.SCHOOL + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( + 'userId', + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); }); - describe('when the user has insufficient permission to read an external tool', () => { - it('should throw UnauthorizedException ', async () => { - const { currentUser } = setupAuthorization(); - const { externalToolId } = setupForSchool(); + describe('when toolPermissionHelper throws ForbiddenException', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.build(); - authorizationService.checkPermissionByReferences.mockImplementation(() => { - throw new UnauthorizedException(); + externalToolService.findExternalTools.mockResolvedValue(new Page([], 0)); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); + toolPermissionHelper.ensureSchoolPermissions.mockImplementation(() => { + throw new ForbiddenException(); }); + }; - const result: Promise = uc.getExternalToolForSchool( - currentUser.userId, - externalToolId, - 'schoolId' - ); - - await expect(result).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('when tool is hidden', () => { - it(' should throw NotFoundException', async () => { - const { currentUser } = setupAuthorization(); - const { externalToolId, externalToolDO } = setupForSchool(); - externalToolDO.isHidden = true; + it('should fail', async () => { + setup(); - const result = uc.getExternalToolForSchool(currentUser.userId, externalToolId, 'schoolId'); + const func = uc.getAvailableToolsForSchool('userId', 'schoolId'); - await expect(result).rejects.toThrow(new NotFoundException('Could not find the Tool Template')); + await expect(func).rejects.toThrow(ForbiddenException); }); }); - }); - describe('getExternalToolForContext is called', () => { - describe('when the user has permission to read an external tool', () => { + describe('when getting the list of external tools that can be added to a school with used tools', () => { const setup = () => { - const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id, schoolId: user.school.id } as ICurrentUser; - const externalToolId: string = new ObjectId().toHexString(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(undefined, externalToolId); - const contextType: ToolContextType = ToolContextType.COURSE; - const contextId: string = new ObjectId().toHexString(); + const externalTools: ExternalTool[] = [ + externalToolFactory.buildWithId(undefined, 'usedToolId'), + externalToolFactory.buildWithId(undefined, 'unusedToolId'), + ]; + const externalToolsPage: Page = new Page(externalTools, 2); - externalToolService.getExternalToolForScope.mockResolvedValue(externalToolDO); + externalToolService.findExternalTools.mockResolvedValue(new Page(externalTools, 2)); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue( + schoolExternalToolFactory.buildList(1, { toolId: 'usedToolId' }) + ); + externalToolConfigurationService.filterForAvailableTools.mockReturnValue(externalTools); - return { - user, - currentUser, - externalToolId, - contextType, - contextId, - }; + return { externalToolsPage }; }; - it('should successfully find the user with the permission', async () => { - const { currentUser, user, externalToolId, contextId, contextType } = setup(); + it('should call externalToolLogoService', async () => { + const { externalToolsPage } = setup(); - await uc.getExternalToolForContext(currentUser.userId, externalToolId, contextId, contextType); + await uc.getAvailableToolsForSchool('userId', 'schoolId'); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.Course, - contextId, - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - } + expect(logoService.buildLogoUrl).toHaveBeenCalledWith( + '/v3/tools/external-tools/{id}/logo', + externalToolsPage.data[1] ); }); - it('should call the externalToolService', async () => { - const { currentUser, externalToolId, contextId, contextType } = setup(); + it('should call filterForAvailableTools with ids of used tools', async () => { + const { externalToolsPage } = setup(); - await uc.getExternalToolForContext(currentUser.userId, externalToolId, contextId, contextType); + await uc.getAvailableToolsForSchool('userId', 'schoolId'); - expect(externalToolService.getExternalToolForScope).toHaveBeenCalledWith( - externalToolId, - CustomParameterScope.CONTEXT - ); + expect(externalToolConfigurationService.filterForAvailableTools).toHaveBeenCalledWith(externalToolsPage, [ + 'usedToolId', + ]); }); }); - describe('when the user has insufficient permission to read an external tool', () => { + describe('when an available external tool has parameters', () => { const setup = () => { - const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id, schoolId: user.school.id } as ICurrentUser; - const externalToolId: string = new ObjectId().toHexString(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(undefined, externalToolId); - const contextType: ToolContextType = ToolContextType.COURSE; - const contextId: string = new ObjectId().toHexString(); - - externalToolService.getExternalToolForScope.mockResolvedValue(externalToolDO); + const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); - return { - currentUser, - externalToolId, - contextType, - contextId, - }; - }; - - it('should throw UnauthorizedException ', async () => { - const { currentUser, externalToolId, contextId, contextType } = setup(); - - authorizationService.checkPermissionByReferences.mockImplementation(() => { - throw new UnauthorizedException(); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + parameters: [globalParameter, schoolParameter, contextParameter], }); - const result: Promise = uc.getExternalToolForContext( - currentUser.userId, - externalToolId, - contextId, - contextType - ); - - await expect(result).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('when tool is hidden', () => { - const setup = () => { - const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id, schoolId: user.school.id } as ICurrentUser; - const externalToolId: string = new ObjectId().toHexString(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(undefined, externalToolId); - const contextType: ToolContextType = ToolContextType.COURSE; - const contextId: string = new ObjectId().toHexString(); - - externalToolService.getExternalToolForScope.mockResolvedValue(externalToolDO); - - return { - currentUser, - externalToolDO, - externalToolId, - contextType, - contextId, - }; - }; - - it(' should throw NotFoundException', async () => { - const { currentUser, externalToolId, externalToolDO, contextId, contextType } = setup(); - externalToolDO.isHidden = true; - - const result = uc.getExternalToolForContext(currentUser.userId, externalToolId, contextId, contextType); - - await expect(result).rejects.toThrow(new NotFoundException('Could not find the Tool Template')); - }); - }); - }); - - describe('getAvailableToolsForSchool is called', () => { - describe('when checking for the users permission', () => { - const setupAuthorization = () => { - const user: User = userFactory.buildWithId(); - const schoolId = 'schoolId'; - - externalToolService.findExternalTools.mockResolvedValue(new Page([], 0)); + externalToolService.findExternalTools.mockResolvedValue(new Page([externalTool], 1)); schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); + externalToolConfigurationService.filterForAvailableTools.mockReturnValue([externalTool]); return { - user, - schoolId, + externalTool, }; }; - it('should call the authorizationService with SCHOOL_TOOL_ADMIN permission', async () => { - const { user, schoolId } = setupAuthorization(); - - await uc.getAvailableToolsForSchool(user.id, 'schoolId'); - - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.School, - schoolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } - ); - }); - - it('should fail when authorizationService throws ForbiddenException', async () => { - setupAuthorization(); - - authorizationService.checkPermissionByReferences.mockImplementation(() => { - throw new ForbiddenException(); - }); + it('should call filterParametersForScope', async () => { + const { externalTool } = setup(); - const func = uc.getAvailableToolsForSchool('userId', 'schoolId'); + await uc.getAvailableToolsForSchool('userId', 'schoolId'); - await expect(func).rejects.toThrow(ForbiddenException); - }); - }); - - describe('when getting the list of external tools that can be added to a school', () => { - it('should filter tools that are already in use', async () => { - const externalToolDOs: ExternalToolDO[] = [ - externalToolDOFactory.buildWithId(undefined, 'usedToolId'), - externalToolDOFactory.buildWithId(undefined, 'unusedToolId'), - ]; - - externalToolService.findExternalTools.mockResolvedValue(new Page(externalToolDOs, 2)); - schoolExternalToolService.findSchoolExternalTools.mockResolvedValue( - schoolExternalToolDOFactory.buildList(1, { toolId: 'usedToolId' }) + expect(externalToolConfigurationService.filterParametersForScope).toHaveBeenCalledWith( + externalTool, + CustomParameterScope.SCHOOL ); - - const result: ExternalToolDO[] = await uc.getAvailableToolsForSchool('userId', 'schoolId'); - - expect(result).toHaveLength(1); - }); - - it('should filter tools that are hidden', async () => { - const externalToolDOs: ExternalToolDO[] = [ - externalToolDOFactory.buildWithId({ isHidden: true }), - externalToolDOFactory.buildWithId({ isHidden: false }), - ]; - - externalToolService.findExternalTools.mockResolvedValue(new Page(externalToolDOs, 2)); - schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); - - const result: ExternalToolDO[] = await uc.getAvailableToolsForSchool('userId', 'schoolId'); - - expect(result).toHaveLength(1); - }); - - it('should return a list of available external tools', async () => { - const externalToolDOs: ExternalToolDO[] = externalToolDOFactory.buildListWithId(2); - - externalToolService.findExternalTools.mockResolvedValue(new Page(externalToolDOs, 2)); - schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); - - const result: ExternalToolDO[] = await uc.getAvailableToolsForSchool('userId', 'schoolId'); - - expect(result).toEqual(externalToolDOs); }); }); }); - describe('getAvailableToolsForContext is called', () => { + describe('getAvailableToolsForContext', () => { describe('when the user has insufficient permission', () => { const setup = () => { - authorizationService.checkPermissionByReferences.mockRejectedValue(new ForbiddenException()); + const tool: ContextExternalTool = contextExternalToolFactory.build(); + + toolPermissionHelper.ensureContextPermissions.mockRejectedValue(new ForbiddenException()); + contextExternalToolService.findContextExternalTools.mockResolvedValue([tool]); + + return { tool }; }; it('should fail when authorizationService throws ForbiddenException', async () => { - setup(); + const { tool } = setup(); const func = async () => uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); await expect(func).rejects.toThrow(ForbiddenException); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( 'userId', - AuthorizableReferenceType.Course, - 'contextId', - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - } + tool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); }); }); describe('when getting the list of external tools that can be added to a school', () => { const setup = () => { - const hiddenTool: ExternalToolDO = externalToolDOFactory.buildWithId({ isHidden: true }); - const usedTool: ExternalToolDO = externalToolDOFactory.buildWithId({ isHidden: false }, 'usedToolId'); - const unusedTool: ExternalToolDO = externalToolDOFactory.buildWithId({ isHidden: false }, 'unusedToolId'); - const toolWithoutSchoolTool: ExternalToolDO = externalToolDOFactory.buildWithId( + const hiddenTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: true }); + const usedTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: false }, 'usedToolId'); + const unusedTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: false }, 'unusedToolId'); + const toolWithoutSchoolTool: ExternalTool = externalToolFactory.buildWithId( { isHidden: false }, 'noSchoolTool' ); - const usedSchoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const usedSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: 'usedSchoolExternalToolId', toolId: 'usedToolId', }); - const unusedSchoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const unusedSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: 'unusedSchoolExternalTool', toolId: 'unusedToolId', }); - const usedContextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ + const usedContextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ schoolToolRef: { schoolToolId: 'usedSchoolExternalToolId' }, }); - const externalTool = unusedTool; - const schoolExternalTool = unusedSchoolExternalTool; + const externalTools: Page = new Page( + [hiddenTool, usedTool, unusedTool, toolWithoutSchoolTool], + 4 + ); + const toolIds = ['usedToolId', 'unusedToolId', 'noSchoolTool']; + const schoolExternalTools = [usedSchoolExternalTool, unusedSchoolExternalTool]; externalToolService.findExternalTools.mockResolvedValue( - new Page([hiddenTool, usedTool, unusedTool, toolWithoutSchoolTool], 4) + new Page([hiddenTool, usedTool, unusedTool, toolWithoutSchoolTool], 4) ); schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([ usedSchoolExternalTool, @@ -421,63 +262,107 @@ describe('ExternalToolConfigurationUc', () => { ]); contextExternalToolService.findContextExternalTools.mockResolvedValue([usedContextExternalTool]); + externalToolConfigurationService.filterForAvailableSchoolExternalTools.mockReturnValue([ + usedSchoolExternalTool, + ]); + externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([ + { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, + ]); + return { + toolIds, + externalTools, + schoolExternalTools, hiddenTool, usedTool, unusedTool, toolWithoutSchoolTool, usedSchoolExternalTool, unusedSchoolExternalTool, - externalTool, - schoolExternalTool, + usedContextExternalTool, }; }; - it('should call the authorizationService with CONTEXT_TOOL_ADMIN permission', async () => { - setup(); + it('should call the toolPermissionHelper with CONTEXT_TOOL_ADMIN permission', async () => { + const { usedContextExternalTool } = setup(); await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( 'userId', - AuthorizableReferenceType.Course, - 'contextId', - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - } + usedContextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); }); - it('should filter tools that are already in use', async () => { - const { usedTool, usedSchoolExternalTool } = setup(); + it('should call externalToolLogoService', async () => { + const { usedTool } = setup(); - const availableTools = await uc.getAvailableToolsForContext( - 'userId', - 'schoolId', - 'contextId', - ToolContextType.COURSE - ); + await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); - expect(availableTools).not.toContain(usedTool); - expect(availableTools).not.toContain(usedSchoolExternalTool); + expect(logoService.buildLogoUrl).toHaveBeenCalledWith('/v3/tools/external-tools/{id}/logo', usedTool); }); - it('should filter tools that are hidden', async () => { - const { hiddenTool } = setup(); + it('should call filterForAvailableSchoolExternalTools', async () => { + const { schoolExternalTools, usedContextExternalTool } = setup(); - const availableTools = await uc.getAvailableToolsForContext( - 'userId', - 'schoolId', - 'contextId', - ToolContextType.COURSE + await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); + + expect(externalToolConfigurationService.filterForAvailableSchoolExternalTools).toHaveBeenCalledWith( + schoolExternalTools, + [usedContextExternalTool] ); + }); + + it('should call filterForAvailableTools', async () => { + const { externalTools, usedSchoolExternalTool } = setup(); - expect(availableTools).not.toContain(hiddenTool); + await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); + + expect(externalToolConfigurationService.filterForAvailableExternalTools).toHaveBeenCalledWith( + externalTools.data, + [usedSchoolExternalTool] + ); }); - it('should filter tools that have no SchoolExternalTool', async () => { - const { toolWithoutSchoolTool } = setup(); + it('should call filterParametersForScope', async () => { + const { usedTool } = setup(); + + await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); + + expect(externalToolConfigurationService.filterParametersForScope).toHaveBeenCalledWith( + usedTool, + CustomParameterScope.CONTEXT + ); + }); + }); + + describe('when there are no available tools', () => { + const setup = () => { + const toolWithoutSchoolTool: ExternalTool = externalToolFactory.buildWithId( + { isHidden: false }, + 'noSchoolTool' + ); + + const unusedSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + id: 'unusedSchoolExternalTool', + toolId: 'unusedToolId', + }); + + externalToolService.findExternalTools.mockResolvedValue(new Page([toolWithoutSchoolTool], 1)); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([unusedSchoolExternalTool]); + contextExternalToolService.findContextExternalTools.mockResolvedValue([]); + + externalToolConfigurationService.filterForAvailableSchoolExternalTools.mockReturnValue([ + unusedSchoolExternalTool, + ]); + externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([]); + + return {}; + }; + + it('should return empty array', async () => { + setup(); const availableTools = await uc.getAvailableToolsForContext( 'userId', @@ -486,11 +371,42 @@ describe('ExternalToolConfigurationUc', () => { ToolContextType.COURSE ); - expect(availableTools).not.toContain(toolWithoutSchoolTool); + expect(availableTools).toEqual([]); }); + }); + + describe('when configuration of context external tools is enabled', () => { + const setup = () => { + const usedTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: false }, 'usedToolId'); + + const usedSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + id: 'usedSchoolExternalToolId', + toolId: 'usedToolId', + }); + + const usedContextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + schoolToolRef: { schoolToolId: 'usedSchoolExternalToolId' }, + }); + + externalToolService.findExternalTools.mockResolvedValue(new Page([usedTool], 1)); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([usedSchoolExternalTool]); + contextExternalToolService.findContextExternalTools.mockResolvedValue([usedContextExternalTool]); + + externalToolConfigurationService.filterForAvailableSchoolExternalTools.mockReturnValue([ + usedSchoolExternalTool, + ]); + externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([ + { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, + ]); - it('should return a list of available external tools', async () => { - const { externalTool, schoolExternalTool } = setup(); + return { + usedTool, + usedSchoolExternalTool, + }; + }; + + it('should allow to add one tool multiple times to a school', async () => { + const { usedTool, usedSchoolExternalTool } = setup(); const availableTools = await uc.getAvailableToolsForContext( 'userId', @@ -501,43 +417,236 @@ describe('ExternalToolConfigurationUc', () => { expect(availableTools).toEqual([ { - externalTool, - schoolExternalTool, + externalTool: usedTool, + schoolExternalTool: usedSchoolExternalTool, }, ]); }); }); + }); - describe('when there are no available tools', () => { + describe('getTemplateForSchoolExternalTool', () => { + describe('when the user has permission to read an external tool', () => { const setup = () => { - const toolWithoutSchoolTool: ExternalToolDO = externalToolDOFactory.buildWithId( - { isHidden: false }, - 'noSchoolTool' + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + const schoolExternalToolId: string = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId( + { + toolId: externalTool.id, + schoolId: 'schoolId', + }, + schoolExternalToolId ); - const unusedSchoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ - id: 'unusedSchoolExternalTool', - toolId: 'unusedToolId', + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + + return { + externalTool, + schoolExternalToolId, + schoolExternalTool, + }; + }; + + it('should successfully check the user permission with the authorization service', async () => { + const { schoolExternalToolId, schoolExternalTool } = setup(); + + await uc.getTemplateForSchoolExternalTool('userId', schoolExternalToolId); + + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( + 'userId', + schoolExternalTool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) + ); + }); + + it('should return the external tool', async () => { + const { schoolExternalToolId, externalTool } = setup(); + + const result = await uc.getTemplateForSchoolExternalTool('userId', schoolExternalToolId); + + expect(result).toEqual(externalTool); + }); + }); + + describe('when the user has insufficient permission to read an external tool', () => { + const setup = () => { + const schoolExternalToolId: string = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId( + undefined, + schoolExternalToolId + ); + + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + toolPermissionHelper.ensureSchoolPermissions.mockImplementation(() => { + throw new UnauthorizedException(); }); - externalToolService.findExternalTools.mockResolvedValue(new Page([toolWithoutSchoolTool], 1)); - schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([unusedSchoolExternalTool]); - contextExternalToolService.findContextExternalTools.mockResolvedValue([]); + return { + schoolExternalToolId, + }; + }; - return {}; + it('should throw UnauthorizedException ', async () => { + const { schoolExternalToolId } = setup(); + + const result = uc.getTemplateForSchoolExternalTool('userId', schoolExternalToolId); + + await expect(result).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('when tool is hidden', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + isHidden: true, + }); + + const schoolExternalToolId: string = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId( + { + toolId: externalTool.id, + schoolId: 'schoolId', + }, + schoolExternalToolId + ); + + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + + return { + schoolExternalToolId, + }; }; - it('should return empty array', async () => { - setup(); + it(' should throw NotFoundException', async () => { + const { schoolExternalToolId } = setup(); - const availableTools = await uc.getAvailableToolsForContext( + const result = uc.getTemplateForSchoolExternalTool('userId', schoolExternalToolId); + + await expect(result).rejects.toThrow(new NotFoundException('Could not find the Tool Template')); + }); + }); + }); + + describe('getTemplateForContextExternalTool', () => { + describe('when the user has permission to read an external tool', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + }); + + const contextExternalToolId: string = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + { + schoolToolRef: { + schoolToolId: schoolExternalTool.schoolId, + }, + contextRef: { + id: new ObjectId().toHexString(), + type: ToolContextType.COURSE, + }, + }, + contextExternalToolId + ); + + contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + + return { + externalTool, + contextExternalTool, + contextExternalToolId, + }; + }; + + it('should successfully check the user permission with the toolPermissionHelper', async () => { + const { contextExternalToolId, contextExternalTool } = setup(); + + await uc.getTemplateForContextExternalTool('userId', contextExternalToolId); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( 'userId', - 'schoolId', - 'contextId', - ToolContextType.COURSE + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); + }); - expect(availableTools).toEqual([]); + it('should return the external tool', async () => { + const { contextExternalToolId, externalTool } = setup(); + + const result = await uc.getTemplateForSchoolExternalTool('userId', contextExternalToolId); + + expect(result).toEqual(externalTool); + }); + }); + + describe('when the user has insufficient permission to read an external tool', () => { + const setup = () => { + const contextExternalToolId: string = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + undefined, + contextExternalToolId + ); + + contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + toolPermissionHelper.ensureContextPermissions.mockImplementation(() => { + throw new UnauthorizedException(); + }); + + return { + contextExternalToolId, + }; + }; + + it('should throw UnauthorizedException ', async () => { + const { contextExternalToolId } = setup(); + + const result = uc.getTemplateForContextExternalTool('userId', contextExternalToolId); + + await expect(result).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('when tool is hidden', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + isHidden: true, + }); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + }); + + const contextExternalToolId: string = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + { + schoolToolRef: { + schoolToolId: schoolExternalTool.schoolId, + }, + }, + contextExternalToolId + ); + + contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + + return { + contextExternalToolId, + }; + }; + + it(' should throw NotFoundException', async () => { + const { contextExternalToolId } = setup(); + + const result = uc.getTemplateForContextExternalTool('userId', contextExternalToolId); + + await expect(result).rejects.toThrow(new NotFoundException('Could not find the Tool Template')); }); }); }); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts index 6a891139419..9607beb84df 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts @@ -1,21 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; -import { - ContextExternalToolDO, - CustomParameterScope, - EntityId, - Permission, - SchoolExternalToolDO, -} from '@shared/domain'; +import { EntityId, Permission } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; -import { ExternalToolDO } from '@shared/domain/domainobject/tool'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; -import { ToolContextType } from '../../common/interface'; -import { ExternalToolService } from '../service'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { CustomParameterScope, ToolContextType } from '../../common/enum'; +import { ContextExternalTool } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { AvailableToolsForContext } from './dto'; -import { ContextTypeMapper } from '../../common/mapper'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ExternalTool } from '../domain'; +import { ExternalToolLogoService, ExternalToolService, ExternalToolConfigurationService } from '../service'; +import { ContextExternalToolTemplateInfo } from './dto'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ExternalToolConfigurationUc { @@ -23,26 +19,45 @@ export class ExternalToolConfigurationUc { private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly authorizationService: AuthorizationService + @Inject(forwardRef(() => ToolPermissionHelper)) + private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly externalToolConfigurationService: ExternalToolConfigurationService, + private readonly externalToolLogoService: ExternalToolLogoService ) {} - public async getAvailableToolsForSchool(userId: EntityId, schoolId: EntityId): Promise { - await this.ensureSchoolPermission(userId, schoolId); - - const externalTools: Page = await this.externalToolService.findExternalTools({}); + public async getAvailableToolsForSchool(userId: EntityId, schoolId: EntityId): Promise { + const externalTools: Page = await this.externalToolService.findExternalTools({}); - const schoolExternalToolsInUse: SchoolExternalToolDO[] = - await this.schoolExternalToolService.findSchoolExternalTools({ + const schoolExternalToolsInUse: SchoolExternalTool[] = await this.schoolExternalToolService.findSchoolExternalTools( + { schoolId, - }); + } + ); + + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + + await this.ensureSchoolPermissions(userId, schoolExternalToolsInUse, context); const toolIdsInUse: EntityId[] = schoolExternalToolsInUse.map( - (schoolExternalTool: SchoolExternalToolDO): EntityId => schoolExternalTool.toolId + (schoolExternalTool: SchoolExternalTool): EntityId => schoolExternalTool.toolId ); - const availableTools: ExternalToolDO[] = externalTools.data.filter( - (tool: ExternalToolDO): boolean => !tool.isHidden && !!tool.id && !toolIdsInUse.includes(tool.id) + const availableTools: ExternalTool[] = this.externalToolConfigurationService.filterForAvailableTools( + externalTools, + toolIdsInUse ); + + availableTools.forEach((externalTool) => { + this.externalToolConfigurationService.filterParametersForScope(externalTool, CustomParameterScope.SCHOOL); + }); + + availableTools.forEach((externalTool) => { + externalTool.logoUrl = this.externalToolLogoService.buildLogoUrl( + '/v3/tools/external-tools/{id}/logo', + externalTool + ); + }); + return availableTools; } @@ -51,13 +66,11 @@ export class ExternalToolConfigurationUc { schoolId: EntityId, contextId: EntityId, contextType: ToolContextType - ): Promise { - await this.ensureContextPermission(userId, contextId, contextType); - + ): Promise { const [externalTools, schoolExternalTools, contextExternalToolsInUse]: [ - Page, - SchoolExternalToolDO[], - ContextExternalToolDO[] + Page, + SchoolExternalTool[], + ContextExternalTool[] ] = await Promise.all([ this.externalToolService.findExternalTools({}), this.schoolExternalToolService.findSchoolExternalTools({ @@ -67,124 +80,111 @@ export class ExternalToolConfigurationUc { context: { id: contextId, type: contextType }, }), ]); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + + await this.ensureContextPermissions(userId, contextExternalToolsInUse, context); + + const availableSchoolExternalTools: SchoolExternalTool[] = + this.externalToolConfigurationService.filterForAvailableSchoolExternalTools( + schoolExternalTools, + contextExternalToolsInUse + ); + + const availableToolsForContext: ContextExternalToolTemplateInfo[] = + this.externalToolConfigurationService.filterForAvailableExternalTools( + externalTools.data, + availableSchoolExternalTools + ); + + availableToolsForContext.forEach((toolTemplateInfo) => { + this.externalToolConfigurationService.filterParametersForScope( + toolTemplateInfo.externalTool, + CustomParameterScope.CONTEXT + ); + }); - const availableSchoolExternalTools: SchoolExternalToolDO[] = this.filterForAvailableSchoolExternalTools( - schoolExternalTools, - contextExternalToolsInUse - ); - - const availableToolsForContext: AvailableToolsForContext[] = this.filterForAvailableExternalTools( - externalTools.data, - availableSchoolExternalTools - ); + availableToolsForContext.forEach((toolTemplateInfo) => { + toolTemplateInfo.externalTool.logoUrl = this.externalToolLogoService.buildLogoUrl( + '/v3/tools/external-tools/{id}/logo', + toolTemplateInfo.externalTool + ); + }); return availableToolsForContext; } - private filterForAvailableSchoolExternalTools( - schoolExternalTools: SchoolExternalToolDO[], - contextExternalToolsInUse: ContextExternalToolDO[] - ): SchoolExternalToolDO[] { - const availableSchoolExternalTools: SchoolExternalToolDO[] = schoolExternalTools.filter( - (schoolExternalTool: SchoolExternalToolDO): boolean => { - const hasContextExternalTool: boolean = contextExternalToolsInUse.some( - (contextExternalTool: ContextExternalToolDO) => - contextExternalTool.schoolToolRef.schoolToolId === schoolExternalTool.id - ); - - return !hasContextExternalTool; - } + public async getTemplateForSchoolExternalTool( + userId: EntityId, + schoolExternalToolId: EntityId + ): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + schoolExternalToolId ); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - return availableSchoolExternalTools; - } + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); - // TODO N21-refactor return null with code coverage - private filterForAvailableExternalTools( - externalTools: ExternalToolDO[], - availableSchoolExternalTools: SchoolExternalToolDO[] - ): AvailableToolsForContext[] { - const toolsWithSchoolTool: (AvailableToolsForContext | null)[] = availableSchoolExternalTools.map( - (schoolExternalTool: SchoolExternalToolDO) => { - const externalTool: ExternalToolDO | undefined = externalTools.find( - (tool: ExternalToolDO) => schoolExternalTool.toolId === tool.id - ); - - if (!externalTool) { - return null; - } - - return { - externalTool, - schoolExternalTool, - }; - } - ); + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); - const availableTools: AvailableToolsForContext[] = toolsWithSchoolTool.filter( - (toolRef): toolRef is AvailableToolsForContext => !!toolRef && !toolRef.externalTool.isHidden - ); + if (externalTool.isHidden) { + throw new NotFoundException('Could not find the Tool Template'); + } - return availableTools; + this.externalToolConfigurationService.filterParametersForScope(externalTool, CustomParameterScope.SCHOOL); + + return externalTool; } - public async getExternalToolForSchool( + public async getTemplateForContextExternalTool( userId: EntityId, - externalToolId: EntityId, - schoolId: EntityId - ): Promise { - await this.ensureSchoolPermission(userId, schoolId); - const externalToolDO: ExternalToolDO = await this.externalToolService.getExternalToolForScope( - externalToolId, - CustomParameterScope.SCHOOL + contextExternalToolId: EntityId + ): Promise { + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( + contextExternalToolId ); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); - if (externalToolDO.isHidden) { - throw new NotFoundException('Could not find the Tool Template'); - } + await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); - return externalToolDO; - } - - public async getExternalToolForContext( - userId: EntityId, - externalToolId: EntityId, - contextId: string, - contextType: ToolContextType - ): Promise { - await this.ensureContextPermission(userId, contextId, contextType); - const externalToolDO: ExternalToolDO = await this.externalToolService.getExternalToolForScope( - externalToolId, - CustomParameterScope.CONTEXT + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + contextExternalTool.schoolToolRef.schoolToolId ); - if (externalToolDO.isHidden) { + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); + + if (externalTool.isHidden) { throw new NotFoundException('Could not find the Tool Template'); } - return externalToolDO; + this.externalToolConfigurationService.filterParametersForScope(externalTool, CustomParameterScope.CONTEXT); + + return { + externalTool, + schoolExternalTool, + }; } - private async ensureSchoolPermission(userId: EntityId, schoolId: EntityId) { - return this.authorizationService.checkPermissionByReferences(userId, AuthorizableReferenceType.School, schoolId, { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - }); + private async ensureSchoolPermissions( + userId: EntityId, + tools: SchoolExternalTool[], + context: AuthorizationContext + ): Promise { + await Promise.all( + tools.map(async (tool: SchoolExternalTool) => + this.toolPermissionHelper.ensureSchoolPermissions(userId, tool, context) + ) + ); } - private async ensureContextPermission( + private async ensureContextPermissions( userId: EntityId, - contextId: EntityId, - contextType: ToolContextType + tools: ContextExternalTool[], + context: AuthorizationContext ): Promise { - return this.authorizationService.checkPermissionByReferences( - userId, - ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(contextType), - contextId, - { - action: Action.read, - requiredPermissions: [Permission.CONTEXT_TOOL_ADMIN], - } + await Promise.all( + tools.map(async (tool: ContextExternalTool) => + this.toolPermissionHelper.ensureContextPermissions(userId, tool, context) + ) ); } } diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index 18f0a26b5e8..d0b02f1e4f3 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -3,19 +3,19 @@ import { UnauthorizedException, UnprocessableEntityException } from '@nestjs/com import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, Permission, SortOrder, User } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; -import { ExternalToolDO, Oauth2ToolConfigDO } from '@shared/domain/domainobject/tool'; import { setupEntities, userFactory } from '@shared/testing'; import { - externalToolDOFactory, - oauth2ToolConfigDOFactory, + externalToolFactory, + oauth2ToolConfigFactory, } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; -import { AuthorizationService } from '@src/modules/authorization'; import { ICurrentUser } from '@src/modules/authentication'; +import { AuthorizationService } from '@src/modules/authorization'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalToolUc } from './external-tool.uc'; -import { ExternalToolService, ExternalToolValidationService } from '../service'; +import { ExternalTool, Oauth2ToolConfig } from '../domain'; +import { ExternalToolLogoService, ExternalToolService, ExternalToolValidationService } from '../service'; import { ExternalToolUpdate } from './dto'; +import { ExternalToolUc } from './external-tool.uc'; describe('ExternalToolUc', () => { let module: TestingModule; @@ -24,6 +24,7 @@ describe('ExternalToolUc', () => { let externalToolService: DeepMocked; let authorizationService: DeepMocked; let toolValidationService: DeepMocked; + let logoService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -43,6 +44,10 @@ describe('ExternalToolUc', () => { provide: ExternalToolValidationService, useValue: createMock(), }, + { + provide: ExternalToolLogoService, + useValue: createMock(), + }, ], }).compile(); @@ -50,6 +55,7 @@ describe('ExternalToolUc', () => { externalToolService = module.get(ExternalToolService); authorizationService = module.get(AuthorizationService); toolValidationService = module.get(ExternalToolValidationService); + logoService = module.get(ExternalToolLogoService); }); afterAll(async () => { @@ -75,13 +81,13 @@ describe('ExternalToolUc', () => { const setup = () => { const toolId = 'toolId'; - const externalToolDO: ExternalToolDO = externalToolDOFactory.withCustomParameters(1).buildWithId(); - const oauth2ConfigWithoutExternalData: Oauth2ToolConfigDO = oauth2ToolConfigDOFactory.build(); + const externalTool: ExternalTool = externalToolFactory.withCustomParameters(1).buildWithId(); + const oauth2ConfigWithoutExternalData: Oauth2ToolConfig = oauth2ToolConfigFactory.build(); const query: ExternalToolSearchQuery = { - name: externalToolDO.name, + name: externalTool.name, }; - const options: IFindOptions = { + const options: IFindOptions = { order: { id: SortOrder.asc, name: SortOrder.asc, @@ -91,16 +97,16 @@ describe('ExternalToolUc', () => { skip: 1, }, }; - const page: Page = new Page( - [externalToolDOFactory.build({ ...externalToolDO, config: oauth2ConfigWithoutExternalData })], + const page: Page = new Page( + [externalToolFactory.build({ ...externalTool, config: oauth2ConfigWithoutExternalData })], 1 ); - externalToolService.createExternalTool.mockResolvedValue(externalToolDO); + externalToolService.createExternalTool.mockResolvedValue(externalTool); externalToolService.findExternalTools.mockResolvedValue(page); return { - externalToolDO, + externalTool, oauth2ConfigWithoutExternalData, options, page, @@ -113,30 +119,30 @@ describe('ExternalToolUc', () => { describe('Authorization', () => { it('should call getUserWithPermissions', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); - await uc.createExternalTool(currentUser.userId, externalToolDO); + await uc.createExternalTool(currentUser.userId, externalTool); expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(currentUser.userId); }); it('should successfully check the user permission with the authorization service', async () => { const { currentUser, user } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); - await uc.createExternalTool(currentUser.userId, externalToolDO); + await uc.createExternalTool(currentUser.userId, externalTool); expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.TOOL_ADMIN]); }); it('should throw if the user has insufficient permission to create an external tool', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); authorizationService.checkAllPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); - const result: Promise = uc.createExternalTool(currentUser.userId, externalToolDO); + const result: Promise = uc.createExternalTool(currentUser.userId, externalTool); await expect(result).rejects.toThrow(UnauthorizedException); }); @@ -144,41 +150,65 @@ describe('ExternalToolUc', () => { it('should validate the tool', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); - await uc.createExternalTool(currentUser.userId, externalToolDO); + await uc.createExternalTool(currentUser.userId, externalTool); - expect(toolValidationService.validateCreate).toHaveBeenCalledWith(externalToolDO); + expect(toolValidationService.validateCreate).toHaveBeenCalledWith(externalTool); }); it('should throw if validation of the tool fails', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); toolValidationService.validateCreate.mockImplementation(() => { throw new UnprocessableEntityException(); }); - const result: Promise = uc.createExternalTool(currentUser.userId, externalToolDO); + const result: Promise = uc.createExternalTool(currentUser.userId, externalTool); await expect(result).rejects.toThrow(UnprocessableEntityException); }); it('should call the service to save a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); - await uc.createExternalTool(currentUser.userId, externalToolDO); + await uc.createExternalTool(currentUser.userId, externalTool); - expect(externalToolService.createExternalTool).toHaveBeenCalledWith(externalToolDO); + expect(externalToolService.createExternalTool).toHaveBeenCalledWith(externalTool); }); it('should return saved a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO } = setup(); + const { externalTool } = setup(); + + const result: ExternalTool = await uc.createExternalTool(currentUser.userId, externalTool); + + expect(result).toEqual(externalTool); + }); + + describe('when fetching logo', () => { + const setupLogo = () => { + const user: User = userFactory.buildWithId(); + const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { + currentUser, + externalTool, + }; + }; + + it('should call ExternalToolLogoService', async () => { + const { currentUser, externalTool } = setupLogo(); - const result: ExternalToolDO = await uc.createExternalTool(currentUser.userId, externalToolDO); + await uc.createExternalTool(currentUser.userId, externalTool); - expect(result).toEqual(externalToolDO); + expect(logoService.fetchLogo).toHaveBeenCalledWith(externalTool); + }); }); }); @@ -209,7 +239,7 @@ describe('ExternalToolUc', () => { throw new UnauthorizedException(); }); - const result: Promise> = uc.findExternalTool(currentUser.userId, query, options); + const result: Promise> = uc.findExternalTool(currentUser.userId, query, options); await expect(result).rejects.toThrow(UnauthorizedException); }); @@ -224,12 +254,12 @@ describe('ExternalToolUc', () => { expect(externalToolService.findExternalTools).toHaveBeenCalledWith(query, options); }); - it('should return a page of externalToolDO', async () => { + it('should return a page of externalTool', async () => { const { currentUser } = setupAuthorization(); const { query, options, page } = setup(); externalToolService.findExternalTools.mockResolvedValue(page); - const resultPage: Page = await uc.findExternalTool(currentUser.userId, query, options); + const resultPage: Page = await uc.findExternalTool(currentUser.userId, query, options); expect(resultPage).toEqual(page); }); @@ -262,7 +292,7 @@ describe('ExternalToolUc', () => { throw new UnauthorizedException(); }); - const result: Promise = uc.getExternalTool(currentUser.userId, toolId); + const result: Promise = uc.getExternalTool(currentUser.userId, toolId); await expect(result).rejects.toThrow(UnauthorizedException); }); @@ -270,37 +300,37 @@ describe('ExternalToolUc', () => { it('should fetch a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalToolDO, toolId } = setup(); - externalToolService.findExternalToolById.mockResolvedValue(externalToolDO); + const { externalTool, toolId } = setup(); + externalToolService.findExternalToolById.mockResolvedValue(externalTool); - const result: ExternalToolDO = await uc.getExternalTool(currentUser.userId, toolId); + const result: ExternalTool = await uc.getExternalTool(currentUser.userId, toolId); - expect(result).toEqual(externalToolDO); + expect(result).toEqual(externalTool); }); }); describe('updateExternalTool', () => { const setupUpdate = () => { - const { externalToolDO, toolId } = setup(); + const { externalTool, toolId } = setup(); const externalToolDOtoUpdate: ExternalToolUpdate = { id: toolId, - ...externalToolDO, + ...externalTool, name: 'newName', url: undefined, version: 1, }; - const updatedExternalToolDO: ExternalToolDO = externalToolDOFactory.build({ - ...externalToolDO, + const updatedExternalToolDO: ExternalTool = externalToolFactory.build({ + ...externalTool, name: 'newName', url: undefined, }); externalToolService.updateExternalTool.mockResolvedValue(updatedExternalToolDO); - externalToolService.findExternalToolById.mockResolvedValue(new ExternalToolDO(externalToolDOtoUpdate)); + externalToolService.findExternalToolById.mockResolvedValue(new ExternalTool(externalToolDOtoUpdate)); return { - externalToolDO, + externalTool, updatedExternalToolDO, externalToolDOtoUpdate, toolId, @@ -333,11 +363,7 @@ describe('ExternalToolUc', () => { throw new UnauthorizedException(); }); - const result: Promise = uc.updateExternalTool( - currentUser.userId, - toolId, - externalToolDOtoUpdate - ); + const result: Promise = uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); await expect(result).rejects.toThrow(UnauthorizedException); }); @@ -359,7 +385,7 @@ describe('ExternalToolUc', () => { throw new UnprocessableEntityException(); }); - const result: Promise = uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); + const result: Promise = uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); await expect(result).rejects.toThrow(UnprocessableEntityException); }); @@ -380,10 +406,34 @@ describe('ExternalToolUc', () => { const { currentUser } = setupAuthorization(); const { toolId, externalToolDOtoUpdate, updatedExternalToolDO } = setupUpdate(); - const result: ExternalToolDO = await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); + const result: ExternalTool = await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); expect(result).toEqual(updatedExternalToolDO); }); + + describe('when fetching logo', () => { + const setupLogo = () => { + const user: User = userFactory.buildWithId(); + const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { + currentUser, + externalTool, + }; + }; + + it('should call ExternalToolLogoService', async () => { + const { currentUser, externalTool } = setupLogo(); + + await uc.createExternalTool(currentUser.userId, externalTool); + + expect(logoService.fetchLogo).toHaveBeenCalledWith(externalTool); + }); + }); }); describe('deleteExternalTool', () => { diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts index a700a100d13..240977b2b38 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts @@ -1,8 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { EntityId, ExternalToolConfigDO, ExternalToolDO, IFindOptions, Page, Permission, User } from '@shared/domain'; +import { EntityId, IFindOptions, Page, Permission, User } from '@shared/domain'; import { AuthorizationService } from '@src/modules/authorization'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalToolService, ExternalToolValidationService } from '../service'; +import { ExternalTool, ExternalToolConfig } from '../domain'; +import { ExternalToolLogoService, ExternalToolService, ExternalToolValidationService } from '../service'; import { ExternalToolCreate, ExternalToolUpdate } from './dto'; @Injectable() @@ -10,56 +11,59 @@ export class ExternalToolUc { constructor( private readonly externalToolService: ExternalToolService, private readonly authorizationService: AuthorizationService, - private readonly toolValidationService: ExternalToolValidationService + private readonly toolValidationService: ExternalToolValidationService, + private readonly externalToolLogoService: ExternalToolLogoService ) {} - async createExternalTool(userId: EntityId, externalToolDO: ExternalToolCreate): Promise { - const externalTool = new ExternalToolDO({ ...externalToolDO }); - + async createExternalTool(userId: EntityId, externalToolCreate: ExternalToolCreate): Promise { await this.ensurePermission(userId, Permission.TOOL_ADMIN); + + const externalTool = new ExternalTool({ ...externalToolCreate }); + externalTool.logo = await this.externalToolLogoService.fetchLogo(externalTool); + await this.toolValidationService.validateCreate(externalTool); - const tool: Promise = this.externalToolService.createExternalTool(externalTool); + const tool: ExternalTool = await this.externalToolService.createExternalTool(externalTool); return tool; } - async updateExternalTool( - userId: EntityId, - toolId: string, - externalTool: ExternalToolUpdate - ): Promise { + async updateExternalTool(userId: EntityId, toolId: string, externalTool: ExternalToolUpdate): Promise { await this.ensurePermission(userId, Permission.TOOL_ADMIN); + + externalTool.logo = await this.externalToolLogoService.fetchLogo(externalTool); + await this.toolValidationService.validateUpdate(toolId, externalTool); - const loaded: ExternalToolDO = await this.externalToolService.findExternalToolById(toolId); - const configToUpdate: ExternalToolConfigDO = { ...loaded.config, ...externalTool.config }; - const toUpdate: ExternalToolDO = new ExternalToolDO({ + const loaded: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + const configToUpdate: ExternalToolConfig = { ...loaded.config, ...externalTool.config }; + const toUpdate: ExternalTool = new ExternalTool({ ...loaded, ...externalTool, config: configToUpdate, version: loaded.version, }); - const saved = await this.externalToolService.updateExternalTool(toUpdate, loaded); + const saved: ExternalTool = await this.externalToolService.updateExternalTool(toUpdate, loaded); + return saved; } async findExternalTool( userId: EntityId, query: ExternalToolSearchQuery, - options: IFindOptions - ): Promise> { + options: IFindOptions + ): Promise> { await this.ensurePermission(userId, Permission.TOOL_ADMIN); - const tools: Page = await this.externalToolService.findExternalTools(query, options); + const tools: Page = await this.externalToolService.findExternalTools(query, options); return tools; } - async getExternalTool(userId: EntityId, toolId: EntityId): Promise { + async getExternalTool(userId: EntityId, toolId: EntityId): Promise { await this.ensurePermission(userId, Permission.TOOL_ADMIN); - const tool: ExternalToolDO = await this.externalToolService.findExternalToolById(toolId); + const tool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); return tool; } diff --git a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts index e8685c67ba8..e06c34e5e8b 100644 --- a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts @@ -1,22 +1,20 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { - ContextExternalToolDO, - ExternalToolDO, - Permission, - SchoolExternalToolDO, - ToolConfigurationStatus, - ToolReference, -} from '@shared/domain'; -import { contextExternalToolDOFactory, externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ForbiddenException } from '@nestjs/common'; -import { Action } from '@src/modules/authorization'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; import { ToolReferenceUc } from './tool-reference.uc'; -import { ToolContextType } from '../../common/interface'; -import { ExternalToolService } from '../service'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalToolService } from '../../context-external-tool/service'; +import { ToolConfigurationStatus, ToolContextType } from '../../common/enum'; import { CommonToolService } from '../../common/service'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalToolService } from '../../context-external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ExternalTool, ToolReference } from '../domain'; +import { ExternalToolLogoService, ExternalToolService } from '../service'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ToolReferenceUc', () => { let module: TestingModule; @@ -25,7 +23,9 @@ describe('ToolReferenceUc', () => { let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; + let toolPermissionHelper: DeepMocked; let commonToolService: DeepMocked; + let logoService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -47,6 +47,14 @@ describe('ToolReferenceUc', () => { provide: CommonToolService, useValue: createMock(), }, + { + provide: ExternalToolLogoService, + useValue: createMock(), + }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, ], }).compile(); @@ -55,7 +63,9 @@ describe('ToolReferenceUc', () => { externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); + toolPermissionHelper = module.get(ToolPermissionHelper); commonToolService = module.get(CommonToolService); + logoService = module.get(ExternalToolLogoService); }); afterAll(async () => { @@ -67,9 +77,11 @@ describe('ToolReferenceUc', () => { const setup = () => { const userId = 'userId'; - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ toolId: externalTool.id }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef('schoolToolId', 'schoolId') .buildWithId(); @@ -77,7 +89,7 @@ describe('ToolReferenceUc', () => { const contextId = 'contextId'; contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); - contextExternalToolService.ensureContextPermissions.mockResolvedValueOnce(); + toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); @@ -93,21 +105,22 @@ describe('ToolReferenceUc', () => { }; }; - it('should call contextExternalToolService.ensureContextPermissions', async () => { + it('should call toolPermissionHelper.ensureContextPermissions', async () => { const { userId, contextType, contextId, contextExternalTool } = setup(); - await uc.getToolReferences(userId, contextType, contextId); + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - expect(contextExternalToolService.ensureContextPermissions).toHaveBeenCalledWith(userId, contextExternalTool, { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - }); + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) + ); }); it('should call contextExternalToolService.findAllByContext', async () => { const { userId, contextType, contextId } = setup(); - await uc.getToolReferences(userId, contextType, contextId); + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith({ type: contextType, @@ -118,7 +131,7 @@ describe('ToolReferenceUc', () => { it('should call schoolExternalToolService.findByExternalToolId', async () => { const { userId, contextType, contextId, contextExternalTool } = setup(); - await uc.getToolReferences(userId, contextType, contextId); + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( contextExternalTool.schoolToolRef.schoolToolId @@ -128,7 +141,7 @@ describe('ToolReferenceUc', () => { it('should call externalToolService.findById', async () => { const { userId, contextType, contextId, externalToolId } = setup(); - await uc.getToolReferences(userId, contextType, contextId); + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(externalToolId); }); @@ -136,7 +149,7 @@ describe('ToolReferenceUc', () => { it('should call commonToolService.determineToolConfigurationStatus', async () => { const { userId, contextType, contextId, contextExternalTool, schoolExternalTool, externalTool } = setup(); - await uc.getToolReferences(userId, contextType, contextId); + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( externalTool, @@ -145,14 +158,29 @@ describe('ToolReferenceUc', () => { ); }); + it('should call externalToolLogoService.buildLogoUrl', async () => { + const { userId, contextType, contextId, externalTool } = setup(); + + await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); + + expect(logoService.buildLogoUrl).toHaveBeenCalledWith('/v3/tools/external-tools/{id}/logo', externalTool); + }); + it('should return a list of tool references', async () => { const { userId, contextType, contextId, contextExternalTool, externalTool } = setup(); - const result: ToolReference[] = await uc.getToolReferences(userId, contextType, contextId); + const result: ToolReference[] = await uc.getToolReferences( + userId, + contextType, + contextId, + '/v3/tools/external-tools/{id}/logo' + ); expect(result).toEqual([ { - logoUrl: externalTool.logoUrl, + logoUrl: `${Configuration.get('PUBLIC_BACKEND_URL') as string}/v3/tools/external-tools/${ + externalTool.id as string + }/logo`, openInNewTab: externalTool.openNewTab, contextToolId: contextExternalTool.id as string, displayName: contextExternalTool.displayName as string, @@ -166,9 +194,11 @@ describe('ToolReferenceUc', () => { const setup = () => { const userId = 'userId'; - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ toolId: externalTool.id }); - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef('schoolToolId', 'schoolId') .buildWithId(); @@ -176,7 +206,7 @@ describe('ToolReferenceUc', () => { const contextId = 'contextId'; contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); - contextExternalToolService.ensureContextPermissions.mockRejectedValueOnce(new ForbiddenException()); + toolPermissionHelper.ensureContextPermissions.mockRejectedValueOnce(new ForbiddenException()); schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); @@ -190,7 +220,12 @@ describe('ToolReferenceUc', () => { it('should filter out tool references if a ForbiddenException is thrown', async () => { const { userId, contextType, contextId } = setup(); - const result: ToolReference[] = await uc.getToolReferences(userId, contextType, contextId); + const result: ToolReference[] = await uc.getToolReferences( + userId, + contextType, + contextId, + '/v3/tools/external-tools/{id}/logo' + ); expect(result).toEqual([]); }); diff --git a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts index 6ebaba89838..5ddf0e467c6 100644 --- a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts @@ -1,21 +1,16 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; -import { ToolReference } from '@shared/domain/domainobject/tool/tool-reference'; -import { - ContextExternalToolDO, - ContextRef, - EntityId, - ExternalToolDO, - Permission, - SchoolExternalToolDO, - ToolConfigurationStatus, -} from '@shared/domain'; -import { Action } from '@src/modules/authorization'; -import { ToolContextType } from '../../common/interface'; -import { ExternalToolService } from '../service'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalToolService } from '../../context-external-tool/service'; +import { EntityId, Permission } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { ExternalTool, ToolReference } from '../domain'; +import { ToolConfigurationStatus, ToolContextType } from '../../common/enum'; import { CommonToolService } from '../../common/service'; +import { ContextExternalTool, ContextRef } from '../../context-external-tool/domain'; +import { ContextExternalToolService } from '../../context-external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ToolReferenceMapper } from '../mapper/tool-reference.mapper'; +import { ExternalToolLogoService, ExternalToolService } from '../service'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ToolReferenceUc { @@ -23,18 +18,26 @@ export class ToolReferenceUc { private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly commonToolService: CommonToolService + private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly commonToolService: CommonToolService, + private readonly externalToolLogoService: ExternalToolLogoService ) {} - async getToolReferences(userId: EntityId, contextType: ToolContextType, contextId: string): Promise { + async getToolReferences( + userId: EntityId, + contextType: ToolContextType, + contextId: string, + logoUrlTemplate: string + ): Promise { const contextRef = new ContextRef({ type: contextType, id: contextId }); - const contextExternalTools: ContextExternalToolDO[] = await this.contextExternalToolService.findAllByContext( + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( contextRef ); const toolReferencesPromises: Promise[] = contextExternalTools.map( - (contextExternalTool: ContextExternalToolDO) => this.buildToolReference(userId, contextExternalTool) + (contextExternalTool: ContextExternalTool) => + this.buildToolReference(userId, contextExternalTool, logoUrlTemplate) ); const toolReferencesWithNull: (ToolReference | null)[] = await Promise.all(toolReferencesPromises); @@ -47,7 +50,8 @@ export class ToolReferenceUc { private async buildToolReference( userId: EntityId, - contextExternalTool: ContextExternalToolDO + contextExternalTool: ContextExternalTool, + logoUrlTemplate: string ): Promise { try { await this.ensureToolPermissions(userId, contextExternalTool); @@ -57,8 +61,8 @@ export class ToolReferenceUc { } } - const schoolExternalTool: SchoolExternalToolDO = await this.fetchSchoolExternalTool(contextExternalTool); - const externalTool: ExternalToolDO = await this.fetchExternalTool(schoolExternalTool); + const schoolExternalTool: SchoolExternalTool = await this.fetchSchoolExternalTool(contextExternalTool); + const externalTool: ExternalTool = await this.fetchExternalTool(schoolExternalTool); const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( externalTool, @@ -71,28 +75,28 @@ export class ToolReferenceUc { contextExternalTool, status ); + toolReference.logoUrl = this.externalToolLogoService.buildLogoUrl(logoUrlTemplate, externalTool); return toolReference; } - private async ensureToolPermissions(userId: EntityId, contextExternalTool: ContextExternalToolDO): Promise { - const promise: Promise = this.contextExternalToolService.ensureContextPermissions( + private async ensureToolPermissions(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + const promise: Promise = this.toolPermissionHelper.ensureContextPermissions( userId, contextExternalTool, - { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - } + context ); return promise; } - private async fetchSchoolExternalTool(contextExternalTool: ContextExternalToolDO): Promise { + private async fetchSchoolExternalTool(contextExternalTool: ContextExternalTool): Promise { return this.schoolExternalToolService.getSchoolExternalToolById(contextExternalTool.schoolToolRef.schoolToolId); } - private async fetchExternalTool(schoolExternalTool: SchoolExternalToolDO): Promise { + private async fetchExternalTool(schoolExternalTool: SchoolExternalTool): Promise { return this.externalToolService.findExternalToolById(schoolExternalTool.toolId); } } diff --git a/apps/server/src/modules/tool/index.ts b/apps/server/src/modules/tool/index.ts index e6376b16872..5af54483f5b 100644 --- a/apps/server/src/modules/tool/index.ts +++ b/apps/server/src/modules/tool/index.ts @@ -1,2 +1,6 @@ +export * from './school-external-tool/entity/school-external-tool.entity'; +export * from './common/entity/custom-parameter-entry.entity'; +export * from './context-external-tool/entity'; +export * from './external-tool'; export * from './tool.module'; export * from './common/interface'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 733fd3bf51b..b9f4241cedd 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -1,56 +1,46 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalTool, Permission, Role, RoleName, School, SchoolExternalTool, User } from '@shared/domain'; +import { Account, Permission, School, User } from '@shared/domain'; import { - externalToolFactory, - mapUserToCurrentUser, - roleFactory, - schoolExternalToolFactory, + accountFactory, + externalToolEntityFactory, + schoolExternalToolEntityFactory, schoolFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@src/modules/server'; import { + CustomParameterEntryParam, SchoolExternalToolPostParams, SchoolExternalToolResponse, SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, } from '../dto'; import { ToolConfigurationStatusResponse } from '../../../external-tool/controller/dto'; +import { SchoolExternalToolEntity } from '../../entity'; +import { ExternalToolEntity } from '../../../external-tool/entity'; describe('ToolSchoolController (API)', () => { let app: INestApplication; let em: EntityManager; let orm: MikroORM; + let testApiClient: TestApiClient; - let currentUser: ICurrentUser; - - const basePath = '/tools/school'; + const basePath = '/tools/school-external-tools'; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleRef.createNestApplication(); await app.init(); em = app.get(EntityManager); orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath); }); afterAll(async () => { @@ -61,97 +51,88 @@ describe('ToolSchoolController (API)', () => { await orm.getSchemaGenerator().clearDatabase(); }); - const setup = async () => { - const adminRole: Role = roleFactory.build({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.SCHOOL_TOOL_ADMIN], - }); - const school: School = schoolFactory.buildWithId(); - - const adminUser: User = userFactory.buildWithId({ school, roles: [adminRole] }); - const userWithMissingPermission: User = userFactory.buildWithId({ school }); + describe('[POST] tools/school-external-tools', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1, parameters: [] }); - const externalTool2: ExternalTool = externalToolFactory.buildWithId({ version: 1, parameters: [] }); - - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool2, - school, - }); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); - em.persist([ - adminRole, - school, - adminUser, - userWithMissingPermission, - externalTool, - externalTool2, - schoolExternalTool, - ]); - await em.flush(); - em.clear(); - - return { - externalTool, - externalTool2, - school, - adminUser, - userWithMissingPermission, - schoolExternalTool, - }; - }; + const userWithMissingPermission: User = userFactory.buildWithId({ school }); + const accountWithMissingPermission: Account = accountFactory.buildWithId({ + userId: userWithMissingPermission.id, + }); - describe('[POST] tools/school', () => { - it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); - const randomTestId = new ObjectId().toString(); - const postParams: SchoolExternalToolPostParams = { - toolId: randomTestId, - schoolId: randomTestId, + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, parameters: [], - }; - - await request(app.getHttpServer()).post(basePath).send(postParams).expect(403); - }); + }); - it('should create an school external tool', async () => { - const { externalTool, school, adminUser } = await setup(); - currentUser = mapUserToCurrentUser(adminUser); - const paramEntry = { name: 'name', value: 'value' }; + const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; const postParams: SchoolExternalToolPostParams = { - toolId: externalTool.id, + toolId: externalToolEntity.id, schoolId: school.id, version: 1, parameters: [paramEntry], }; - await request(app.getHttpServer()) - .post(basePath) - .send(postParams) - .expect(201) - .then((res: Response) => { - expect(res.body).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: externalTool.name, - schoolId: postParams.schoolId, - toolId: postParams.toolId, - status: ToolConfigurationStatusResponse.LATEST, - toolVersion: postParams.version, - parameters: [ - { - name: paramEntry.name, - value: paramEntry.value, - }, - ], - }) - ); - return res; - }); + const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ + id: expect.any(String), + name: externalToolEntity.name, + schoolId: postParams.schoolId, + toolId: postParams.toolId, + status: ToolConfigurationStatusResponse.LATEST, + toolVersion: postParams.version, + parameters: [ + { + name: paramEntry.name, + value: paramEntry.value, + }, + ], + }); + + em.persist([ + school, + adminUser, + adminAccount, + userWithMissingPermission, + accountWithMissingPermission, + externalToolEntity, + ]); + await em.flush(); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + const loggedInClientWithMissingPermission: TestApiClient = await testApiClient.login( + accountWithMissingPermission + ); + + return { + loggedInClientWithMissingPermission, + loggedInClient, + postParams, + schoolExternalToolResponse, + }; + }; - const createdSchoolExternalTool: SchoolExternalTool | null = await em.findOne(SchoolExternalTool, { + it('should return forbidden when user is not authorized', async () => { + const { loggedInClientWithMissingPermission, postParams } = await setup(); + + const response = await loggedInClientWithMissingPermission.post().send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + }); + + it('should create an school external tool', async () => { + const { loggedInClient, postParams, schoolExternalToolResponse } = await setup(); + + const response = await loggedInClient.post().send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual(schoolExternalToolResponse); + + const createdSchoolExternalTool: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { school: postParams.schoolId, tool: postParams.toolId, }); @@ -159,164 +140,350 @@ describe('ToolSchoolController (API)', () => { }); }); - describe('[DELETE] tools/school/:schoolExternalToolId', () => { + describe('[DELETE] tools/school-external-tools/:schoolExternalToolId', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + + const userWithMissingPermission: User = userFactory.buildWithId({ school }); + const accountWithMissingPermission: Account = accountFactory.buildWithId({ + userId: userWithMissingPermission.id, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + version: 1, + parameters: [], + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + }); + + em.persist([ + school, + adminUser, + adminAccount, + userWithMissingPermission, + accountWithMissingPermission, + externalToolEntity, + schoolExternalToolEntity, + ]); + await em.flush(); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + const loggedInClientWithMissingPermission: TestApiClient = await testApiClient.login( + accountWithMissingPermission + ); + + return { loggedInClientWithMissingPermission, loggedInClient, schoolExternalToolEntity }; + }; + it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); + const { loggedInClientWithMissingPermission, schoolExternalToolEntity } = await setup(); - await request(app.getHttpServer()).delete(`${basePath}/${schoolExternalTool.id}`).expect(403); + const response = await loggedInClientWithMissingPermission.delete(`${schoolExternalToolEntity.id}`); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); - it('should create an school external tool', async () => { - const { adminUser, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(adminUser); + it('should create a school external tool', async () => { + const { loggedInClient, schoolExternalToolEntity } = await setup(); - await request(app.getHttpServer()).delete(`${basePath}/${schoolExternalTool.id}`).expect(200); + const response = await loggedInClient.delete(`${schoolExternalToolEntity.id}`); - const deleted: SchoolExternalTool | null = await em.findOne(SchoolExternalTool, { - id: schoolExternalTool.id, + expect(response.statusCode).toEqual(HttpStatus.NO_CONTENT); + + const deleted: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { + id: schoolExternalToolEntity.id, }); expect(deleted).toBeNull(); }); }); - describe('[GET] tools/school/', () => { - it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission, school } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); + describe('[GET] tools/school-external-tools/', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + + const userWithMissingPermission: User = userFactory.buildWithId({ school }); + const accountWithMissingPermission: Account = accountFactory.buildWithId({ + userId: userWithMissingPermission.id, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + version: 1, + parameters: [], + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + }); + const params: SchoolExternalToolSearchParams = { schoolId: school.id, }; - await request(app.getHttpServer()).get(basePath).query(params).expect(403); + em.persist([ + school, + adminUser, + adminAccount, + userWithMissingPermission, + accountWithMissingPermission, + externalToolEntity, + schoolExternalToolEntity, + ]); + await em.flush(); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + const loggedInClientWithMissingPermission: TestApiClient = await testApiClient.login( + accountWithMissingPermission + ); + + return { + loggedInClientWithMissingPermission, + loggedInClient, + externalToolEntity, + schoolExternalToolEntity, + params, + school, + }; + }; + + it('should return forbidden when user is not authorized', async () => { + const { loggedInClientWithMissingPermission, params } = await setup(); + + const response = await loggedInClientWithMissingPermission.get().query(params); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); it('should return found schoolExternalTools for given school', async () => { - const { adminUser, schoolExternalTool, externalTool2, school } = await setup(); - currentUser = mapUserToCurrentUser(adminUser); - const params: SchoolExternalToolSearchParams = { - schoolId: school.id, - }; + const { loggedInClient, schoolExternalToolEntity, externalToolEntity, params, school } = await setup(); + + const response = await loggedInClient.get().query(params); - await request(app.getHttpServer()) - .get(basePath) - .query(params) - .expect(200) - .then((res: Response) => { - expect(res.body).toEqual( - expect.objectContaining({ - data: [ + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual( + expect.objectContaining({ + data: [ + { + id: schoolExternalToolEntity.id, + name: externalToolEntity.name, + schoolId: school.id, + toolId: externalToolEntity.id, + status: ToolConfigurationStatusResponse.OUTDATED, + toolVersion: schoolExternalToolEntity.toolVersion, + parameters: [ { - id: schoolExternalTool.id, - name: externalTool2.name, - schoolId: school.id, - toolId: externalTool2.id, - status: ToolConfigurationStatusResponse.OUTDATED, - toolVersion: schoolExternalTool.toolVersion, - parameters: [ - { - name: schoolExternalTool.schoolParameters[0].name, - value: schoolExternalTool.schoolParameters[0].value, - }, - ], + name: schoolExternalToolEntity.schoolParameters[0].name, + value: schoolExternalToolEntity.schoolParameters[0].value, }, ], - }) - ); - return res; - }); + }, + ], + }) + ); }); }); - describe('[GET] tools/school/:schoolExternalToolId', () => { + describe('[GET] tools/school-external-tools/:schoolExternalToolId', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + + const userWithMissingPermission: User = userFactory.buildWithId({ school }); + const accountWithMissingPermission: Account = accountFactory.buildWithId({ + userId: userWithMissingPermission.id, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + version: 1, + parameters: [], + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + }); + + const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ + id: schoolExternalToolEntity.id, + name: '', + schoolId: school.id, + toolId: externalToolEntity.id, + status: ToolConfigurationStatusResponse.UNKNOWN, + toolVersion: schoolExternalToolEntity.toolVersion, + parameters: [ + { + name: schoolExternalToolEntity.schoolParameters[0].name, + value: schoolExternalToolEntity.schoolParameters[0].value, + }, + ], + }); + + em.persist([ + school, + adminUser, + adminAccount, + userWithMissingPermission, + accountWithMissingPermission, + externalToolEntity, + schoolExternalToolEntity, + ]); + await em.flush(); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + const loggedInClientWithMissingPermission: TestApiClient = await testApiClient.login( + accountWithMissingPermission + ); + + return { + loggedInClientWithMissingPermission, + loggedInClient, + schoolExternalToolEntity, + schoolExternalToolResponse, + }; + }; + it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); + const { loggedInClientWithMissingPermission, schoolExternalToolEntity } = await setup(); + + const response = await loggedInClientWithMissingPermission.get(`${schoolExternalToolEntity.id}`); - await request(app.getHttpServer()).get(`${basePath}/${schoolExternalTool.id}`).expect(403); + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); it('should return found schoolExternalTool for given school', async () => { - const { adminUser, schoolExternalTool, externalTool2, school } = await setup(); - currentUser = mapUserToCurrentUser(adminUser); - - await request(app.getHttpServer()) - .get(`${basePath}/${schoolExternalTool.id}`) - .expect(200) - .then((res: Response) => { - expect(res.body).toEqual( - expect.objectContaining({ - id: schoolExternalTool.id, - name: '', - schoolId: school.id, - toolId: externalTool2.id, - status: ToolConfigurationStatusResponse.UNKNOWN, - toolVersion: schoolExternalTool.toolVersion, - parameters: [ - { - name: schoolExternalTool.schoolParameters[0].name, - value: schoolExternalTool.schoolParameters[0].value, - }, - ], - }) - ); - return res; - }); + const { loggedInClient, schoolExternalToolEntity, schoolExternalToolResponse } = await setup(); + + const response = await loggedInClient.get(`${schoolExternalToolEntity.id}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(schoolExternalToolResponse); }); }); - describe('[PUT] tools/school/:schoolExternalToolId', () => { - it('should return forbidden when user is not authorized', async () => { - const { userWithMissingPermission, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(userWithMissingPermission); - const paramEntry = { name: 'name', value: 'Updatedvalue' }; - const randomTestId = new ObjectId().toString(); + describe('[PUT] tools/school-external-tools/:schoolExternalToolId', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + const userWithMissingPermission: User = userFactory.buildWithId({ school }); + const accountWithMissingPermission: Account = accountFactory.buildWithId({ + userId: userWithMissingPermission.id, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + version: 1, + parameters: [], + }); + const externalToolEntity2: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + version: 1, + parameters: [], + }); + + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity2, + school, + }); + + em.persist([ + adminUser, + adminAccount, + school, + adminUser, + userWithMissingPermission, + accountWithMissingPermission, + externalToolEntity, + externalToolEntity2, + schoolExternalToolEntity, + ]); + await em.flush(); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + const loggedInClientWithMissingPermission: TestApiClient = await testApiClient.login( + accountWithMissingPermission + ); + + const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; const postParams: SchoolExternalToolPostParams = { - toolId: randomTestId, - schoolId: randomTestId, + toolId: externalToolEntity.id, + schoolId: school.id, version: 1, parameters: [paramEntry], }; - await request(app.getHttpServer()).put(`${basePath}/${schoolExternalTool.id}`).send(postParams).expect(403); - }); - it('should update an existing school external tool', async () => { - const { externalTool, school, adminUser, schoolExternalTool } = await setup(); - currentUser = mapUserToCurrentUser(adminUser); - const paramEntry = { name: 'name', value: 'Updatedvalue' }; - const postParams: SchoolExternalToolPostParams = { - toolId: externalTool.id, + const updatedParamEntry: CustomParameterEntryParam = { name: 'name', value: 'updatedValue' }; + const postParamsUpdate: SchoolExternalToolPostParams = { + toolId: externalToolEntity.id, schoolId: school.id, version: 1, - parameters: [paramEntry], + parameters: [updatedParamEntry], }; - await request(app.getHttpServer()) - .put(`${basePath}/${schoolExternalTool.id}`) - .send(postParams) - .expect(200) - .then((res: Response) => { - expect(res.body).toEqual( - expect.objectContaining({ - id: schoolExternalTool.id, - name: externalTool.name, - schoolId: postParams.schoolId, - toolId: postParams.toolId, - status: ToolConfigurationStatusResponse.LATEST, - toolVersion: postParams.version, - parameters: [ - { - name: paramEntry.name, - value: paramEntry.value, - }, - ], - }) - ); - return res; - }); - const updatedSchoolExternalTool: SchoolExternalTool | null = await em.findOne(SchoolExternalTool, { - school: postParams.schoolId, - tool: postParams.toolId, + + const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ + id: schoolExternalToolEntity.id, + name: externalToolEntity.name, + schoolId: postParamsUpdate.schoolId, + toolId: postParamsUpdate.toolId, + status: ToolConfigurationStatusResponse.LATEST, + toolVersion: postParamsUpdate.version, + parameters: [ + { + name: updatedParamEntry.name, + value: updatedParamEntry.value, + }, + ], + }); + + return { + postParams, + postParamsUpdate, + loggedInClient, + loggedInClientWithMissingPermission, + schoolExternalToolEntity, + schoolExternalToolResponse, + }; + }; + + it('should return forbidden when user is not authorized', async () => { + const { loggedInClientWithMissingPermission, schoolExternalToolEntity, postParams } = await setup(); + + const response = await loggedInClientWithMissingPermission.put(`${schoolExternalToolEntity.id}`).send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + }); + + it('should update an existing school external tool', async () => { + const { loggedInClient, schoolExternalToolEntity, postParamsUpdate, schoolExternalToolResponse } = await setup(); + + const response = await loggedInClient.put(`${schoolExternalToolEntity.id}`).send(postParamsUpdate); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(schoolExternalToolResponse); + + const updatedSchoolExternalTool: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { + school: postParamsUpdate.schoolId, + tool: postParamsUpdate.toolId, }); + expect(updatedSchoolExternalTool).toBeDefined(); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts index 0d968f1ea79..5c3075e1cd2 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts @@ -4,6 +4,4 @@ export * from './school-external-tool-id.params'; export * from './custom-parameter-entry.response'; export * from './school-external-tool-post.params'; export * from './school-external-tool-search.params'; -export * from './school-tool-configuration-list.response'; -export * from './school-tool-configuration-entry.response'; export * from './school-external-tool-search-list.response'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts index addd844952c..62ad203fb02 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomParameterEntryResponse } from './custom-parameter-entry.response'; import { ToolConfigurationStatusResponse } from '../../../external-tool/controller/dto'; @@ -24,6 +24,9 @@ export class SchoolExternalToolResponse { @ApiProperty({ enum: ToolConfigurationStatusResponse }) status: ToolConfigurationStatusResponse; + @ApiPropertyOptional() + logoUrl?: string; + constructor(response: SchoolExternalToolResponse) { this.id = response.id; this.name = response.name; @@ -32,5 +35,6 @@ export class SchoolExternalToolResponse { this.parameters = response.parameters; this.toolVersion = response.toolVersion; this.status = response.status; + this.logoUrl = response.logoUrl; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-entry.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-entry.response.ts deleted file mode 100644 index a361af54d6b..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-entry.response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { ToolConfigurationEntryResponse } from '../../../external-tool/controller/dto'; - -export class SchoolToolConfigurationEntryResponse extends ToolConfigurationEntryResponse { - @ApiProperty() - schoolToolId: string; - - constructor(response: ToolConfigurationEntryResponse, schoolToolId: string) { - super(response); - this.schoolToolId = schoolToolId; - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-list.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-list.response.ts deleted file mode 100644 index faedc1b9580..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-tool-configuration-list.response.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { SchoolToolConfigurationEntryResponse } from './school-tool-configuration-entry.response'; - -export class SchoolToolConfigurationListResponse { - @ApiProperty({ type: [SchoolToolConfigurationEntryResponse] }) - data: SchoolToolConfigurationEntryResponse[]; - - constructor(data: SchoolToolConfigurationEntryResponse[]) { - this.data = data; - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.spec.ts deleted file mode 100644 index 9e8d82b104f..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { schoolExternalToolDOFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; -import { SchoolExternalToolDO } from '@shared/domain/domainobject/tool/school-external-tool.do'; -import { ICurrentUser } from '@src/modules/authentication'; -import { LegacyLogger } from '@src/core/logger'; -import { ToolSchoolController } from './tool-school.controller'; -import { SchoolExternalToolUc } from '../uc'; -import { SchoolExternalToolResponseMapper, SchoolExternalToolRequestMapper } from '../mapper'; -import { - SchoolExternalToolIdParams, - SchoolExternalToolPostParams, - SchoolExternalToolResponse, - SchoolExternalToolSearchListResponse, - SchoolExternalToolSearchParams, -} from './dto'; -import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; - -describe('ToolSchoolController', () => { - let module: TestingModule; - let controller: ToolSchoolController; - - let schoolExternalToolUc: DeepMocked; - let schoolExternalToolResponseMapper: DeepMocked; - let schoolExternalToolRequestMapper: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - ToolSchoolController, - { - provide: SchoolExternalToolUc, - useValue: createMock(), - }, - { - provide: SchoolExternalToolResponseMapper, - useValue: createMock(), - }, - { - provide: SchoolExternalToolRequestMapper, - useValue: createMock(), - }, - { - provide: LegacyLogger, - useValue: createMock(), - }, - ], - }).compile(); - - controller = module.get(ToolSchoolController); - schoolExternalToolUc = module.get(SchoolExternalToolUc); - schoolExternalToolResponseMapper = module.get(SchoolExternalToolResponseMapper); - schoolExternalToolRequestMapper = module.get(SchoolExternalToolRequestMapper); - }); - - afterAll(async () => { - await module.close(); - }); - - const setup = () => { - const currentUser: ICurrentUser = { userId: 'userId' } as ICurrentUser; - const searchParams: SchoolExternalToolSearchParams = new SchoolExternalToolSearchParams(); - searchParams.schoolId = 'schoolId'; - - const idParams: SchoolExternalToolIdParams = new SchoolExternalToolIdParams(); - idParams.schoolExternalToolId = 'schoolExternalToolId'; - - const createParams: SchoolExternalToolPostParams = { - toolId: 'toolId', - version: 1, - schoolId: 'schoolId', - parameters: [ - { - name: 'name', - value: 'value', - }, - ], - }; - - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - - return { - currentUser, - searchParams, - idParams, - createParams, - schoolExternalToolDO, - }; - }; - - describe('getSchoolExternalTools is called', () => { - describe('when endpoint is called', () => { - it('should call the uc', async () => { - const { currentUser, searchParams } = setup(); - - await controller.getSchoolExternalTools(currentUser, searchParams); - - expect(schoolExternalToolUc.findSchoolExternalTools).toHaveBeenCalledWith(currentUser.userId, { - schoolId: searchParams.schoolId, - }); - }); - - it('should call the response mapper', async () => { - const { currentUser, searchParams } = setup(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - schoolExternalToolUc.findSchoolExternalTools.mockResolvedValue([schoolExternalToolDO]); - - await controller.getSchoolExternalTools(currentUser, searchParams); - - expect(schoolExternalToolResponseMapper.mapToSearchListResponse).toHaveBeenCalledWith([schoolExternalToolDO]); - }); - - it('should return a schoolExternalToolSearchListResponse', async () => { - const { currentUser, searchParams } = setup(); - const expectedResponse: SchoolExternalToolSearchListResponse = new SchoolExternalToolSearchListResponse([ - new SchoolExternalToolResponse({ - id: 'id', - name: 'name', - schoolId: 'schoolId', - toolId: 'toolId', - toolVersion: 2, - parameters: [ - { - name: 'name', - value: 'value', - }, - ], - status: ToolConfigurationStatusResponse.LATEST, - }), - ]); - - schoolExternalToolResponseMapper.mapToSearchListResponse.mockReturnValue(expectedResponse); - - const response = await controller.getSchoolExternalTools(currentUser, searchParams); - - expect(response).toEqual(expectedResponse); - }); - }); - }); - - describe('deleteSchoolExternalTool is called', () => { - describe('when params are given', () => { - it('should call the uc', async () => { - const { currentUser, idParams } = setup(); - - await controller.deleteSchoolExternalTool(currentUser, idParams); - - expect(schoolExternalToolUc.deleteSchoolExternalTool).toHaveBeenCalledWith( - currentUser.userId, - idParams.schoolExternalToolId - ); - }); - }); - }); - - describe('createSchoolExternalTool is called', () => { - describe('when params are given', () => { - it('should call the schoolExternalToolRequestMapper', async () => { - const { currentUser, createParams } = setup(); - - await controller.createSchoolExternalTool(currentUser, createParams); - - expect(schoolExternalToolRequestMapper.mapSchoolExternalToolRequest).toHaveBeenCalledWith(createParams); - }); - - it('should call the uc', async () => { - const { currentUser, createParams, schoolExternalToolDO } = setup(); - schoolExternalToolRequestMapper.mapSchoolExternalToolRequest.mockReturnValue(schoolExternalToolDO); - - await controller.createSchoolExternalTool(currentUser, createParams); - - expect(schoolExternalToolUc.createSchoolExternalTool).toHaveBeenCalledWith( - currentUser.userId, - schoolExternalToolDO - ); - }); - - it('should call the schoolExternalToolRequestMapper', async () => { - const { currentUser, createParams, schoolExternalToolDO } = setup(); - schoolExternalToolUc.createSchoolExternalTool.mockResolvedValue(schoolExternalToolDO); - - await controller.createSchoolExternalTool(currentUser, createParams); - - expect(schoolExternalToolResponseMapper.mapToSchoolExternalToolResponse).toHaveBeenCalledWith( - schoolExternalToolDO - ); - }); - - it('should return a schoolExternalToolResponse', async () => { - const { currentUser, createParams, schoolExternalToolDO } = setup(); - schoolExternalToolRequestMapper.mapSchoolExternalToolRequest.mockReturnValue(schoolExternalToolDO); - schoolExternalToolUc.createSchoolExternalTool.mockResolvedValue(schoolExternalToolDO); - schoolExternalToolResponseMapper.mapToSchoolExternalToolResponse.mockReturnValue({ - name: 'name', - parameters: [], - schoolId: 'schoolId', - status: ToolConfigurationStatusResponse.LATEST, - toolId: 'toolId', - toolVersion: 0, - id: 'id', - }); - - const schoolExternalToolResponse = await controller.createSchoolExternalTool(currentUser, createParams); - - expect(schoolExternalToolResponse).toBeDefined(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts index ea276675754..c34b669f75c 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts @@ -8,9 +8,9 @@ import { ApiTags, ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, + ApiOperation, } from '@nestjs/swagger'; -import { Body, Controller, Delete, Get, Param, Post, Query, Put } from '@nestjs/common'; -import { SchoolExternalToolDO } from '@shared/domain/domainobject/tool'; +import { Body, Controller, Delete, Get, Param, Post, Query, Put, HttpCode, HttpStatus } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; import { LegacyLogger } from '@src/core/logger'; @@ -24,12 +24,13 @@ import { SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, } from './dto'; -import { SchoolExternalTool } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { SchoolExternalToolUc } from '../uc'; +import { SchoolExternalTool } from '../domain'; @ApiTags('Tool') @Authenticate('jwt') -@Controller('tools/school') +@Controller('tools/school-external-tools') export class ToolSchoolController { constructor( private readonly schoolExternalToolUc: SchoolExternalToolUc, @@ -42,11 +43,12 @@ export class ToolSchoolController { @ApiFoundResponse({ description: 'SchoolExternalTools has been found.', type: ExternalToolSearchListResponse }) @ApiForbiddenResponse() @ApiUnauthorizedResponse() + @ApiOperation({ summary: 'Returns a list of SchoolExternalTools for a given school' }) async getSchoolExternalTools( @CurrentUser() currentUser: ICurrentUser, @Query() schoolExternalToolParams: SchoolExternalToolSearchParams ): Promise { - const found: SchoolExternalToolDO[] = await this.schoolExternalToolUc.findSchoolExternalTools(currentUser.userId, { + const found: SchoolExternalTool[] = await this.schoolExternalToolUc.findSchoolExternalTools(currentUser.userId, { schoolId: schoolExternalToolParams.schoolId, }); const response: SchoolExternalToolSearchListResponse = this.responseMapper.mapToSearchListResponse(found); @@ -56,16 +58,16 @@ export class ToolSchoolController { @Get(':schoolExternalToolId') @ApiForbiddenResponse() @ApiUnauthorizedResponse() + @ApiOperation({ summary: 'Returns a SchoolExternalTool for the given id' }) async getSchoolExternalTool( @CurrentUser() currentUser: ICurrentUser, @Param() params: SchoolExternalToolIdParams ): Promise { - const schoolExternalToolDO: SchoolExternalToolDO = await this.schoolExternalToolUc.getSchoolExternalTool( + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolUc.getSchoolExternalTool( currentUser.userId, params.schoolExternalToolId ); - const mapped: SchoolExternalToolResponse = - this.responseMapper.mapToSchoolExternalToolResponse(schoolExternalToolDO); + const mapped: SchoolExternalToolResponse = this.responseMapper.mapToSchoolExternalToolResponse(schoolExternalTool); return mapped; } @@ -74,16 +76,17 @@ export class ToolSchoolController { @ApiForbiddenResponse() @ApiUnauthorizedResponse() @ApiBadRequestResponse({ type: ValidationError, description: 'Request data has invalid format.' }) + @ApiOperation({ summary: 'Updates a SchoolExternalTool' }) async updateSchoolExternalTool( @CurrentUser() currentUser: ICurrentUser, @Param() params: SchoolExternalToolIdParams, @Body() body: SchoolExternalToolPostParams ): Promise { - const schoolExternalTool: SchoolExternalTool = this.requestMapper.mapSchoolExternalToolRequest(body); - const updated: SchoolExternalToolDO = await this.schoolExternalToolUc.updateSchoolExternalTool( + const schoolExternalToolDto: SchoolExternalToolDto = this.requestMapper.mapSchoolExternalToolRequest(body); + const updated: SchoolExternalTool = await this.schoolExternalToolUc.updateSchoolExternalTool( currentUser.userId, params.schoolExternalToolId, - schoolExternalTool + schoolExternalToolDto ); const mapped: SchoolExternalToolResponse = this.responseMapper.mapToSchoolExternalToolResponse(updated); @@ -94,6 +97,8 @@ export class ToolSchoolController { @Delete(':schoolExternalToolId') @ApiForbiddenResponse() @ApiUnauthorizedResponse() + @ApiOperation({ summary: 'Deletes a SchoolExternalTool' }) + @HttpCode(HttpStatus.NO_CONTENT) async deleteSchoolExternalTool( @CurrentUser() currentUser: ICurrentUser, @Param() params: SchoolExternalToolIdParams @@ -113,15 +118,16 @@ export class ToolSchoolController { @ApiUnprocessableEntityResponse() @ApiUnauthorizedResponse() @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiOperation({ summary: 'Creates a SchoolExternalTool' }) async createSchoolExternalTool( @CurrentUser() currentUser: ICurrentUser, @Body() body: SchoolExternalToolPostParams ): Promise { - const schoolExternalTool: SchoolExternalTool = this.requestMapper.mapSchoolExternalToolRequest(body); + const schoolExternalToolDto: SchoolExternalToolDto = this.requestMapper.mapSchoolExternalToolRequest(body); - const createdSchoolExternalToolDO: SchoolExternalToolDO = await this.schoolExternalToolUc.createSchoolExternalTool( + const createdSchoolExternalToolDO: SchoolExternalTool = await this.schoolExternalToolUc.createSchoolExternalTool( currentUser.userId, - schoolExternalTool + schoolExternalToolDto ); const response: SchoolExternalToolResponse = diff --git a/apps/server/src/modules/tool/school-external-tool/domain/index.ts b/apps/server/src/modules/tool/school-external-tool/domain/index.ts new file mode 100644 index 00000000000..1d734ed8376 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/domain/index.ts @@ -0,0 +1,2 @@ +export * from './school-external-tool.do'; +export * from './school-external-tool-ref.do'; diff --git a/apps/server/src/shared/domain/domainobject/tool/school-external-tool-ref.do.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-ref.do.ts similarity index 100% rename from apps/server/src/shared/domain/domainobject/tool/school-external-tool-ref.do.ts rename to apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-ref.do.ts diff --git a/apps/server/src/shared/domain/domainobject/tool/school-external-tool.do.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts similarity index 61% rename from apps/server/src/shared/domain/domainobject/tool/school-external-tool.do.ts rename to apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts index db238d2f56a..621ea1f6366 100644 --- a/apps/server/src/shared/domain/domainobject/tool/school-external-tool.do.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts @@ -1,7 +1,7 @@ -import { CustomParameterEntryDO } from './custom-parameter-entry.do'; -import { BaseDO } from '../base.do'; -import { ToolConfigurationStatus } from './tool-configuration-status'; -import { ToolVersion } from './types'; +import { BaseDO } from '@shared/domain/domainobject/base.do'; +import { CustomParameterEntry } from '../../common/domain'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { ToolVersion } from '../../common/interface'; export interface SchoolExternalToolProps { id?: string; @@ -12,21 +12,21 @@ export interface SchoolExternalToolProps { schoolId: string; - parameters: CustomParameterEntryDO[]; + parameters: CustomParameterEntry[]; toolVersion: number; status?: ToolConfigurationStatus; } -export class SchoolExternalToolDO extends BaseDO implements ToolVersion { +export class SchoolExternalTool extends BaseDO implements ToolVersion { name?: string; toolId: string; schoolId: string; - parameters: CustomParameterEntryDO[]; + parameters: CustomParameterEntry[]; toolVersion: number; diff --git a/apps/server/src/modules/tool/school-external-tool/entity/index.ts b/apps/server/src/modules/tool/school-external-tool/entity/index.ts new file mode 100644 index 00000000000..6f4ad1c134b --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/index.ts @@ -0,0 +1 @@ +export * from './school-external-tool.entity'; diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts new file mode 100644 index 00000000000..17344cd0579 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts @@ -0,0 +1,65 @@ +import { schoolFactory, setupEntities } from '@shared/testing'; +import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; +import { + BasicToolConfigEntity, + CustomParameterEntity, + ExternalToolEntity, + ExternalToolConfigEntity, +} from '../../external-tool/entity'; +import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; +import { SchoolExternalToolEntity } from './school-external-tool.entity'; + +describe('SchoolExternalToolEntity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new SchoolExternalToolEntity(); + expect(test).toThrow(); + }); + + it('should create an external school Tool by passing required properties', () => { + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); + expect(schoolExternalToolEntity instanceof SchoolExternalToolEntity).toEqual(true); + }); + + it('should set schoolParameters to empty when is undefined', () => { + const externalToolConfigEntity: ExternalToolConfigEntity = new BasicToolConfigEntity({ + type: ToolConfigType.OAUTH2, + baseUrl: 'mockBaseUrl', + }); + const customParameter: CustomParameterEntity = new CustomParameterEntity({ + name: 'parameterName', + displayName: 'User Friendly Name', + default: 'mock', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + }); + const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ + name: 'toolName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + config: externalToolConfigEntity, + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = new SchoolExternalToolEntity({ + tool: externalToolEntity, + school: schoolFactory.buildWithId(), + schoolParameters: [], + toolVersion: 1, + }); + + expect(schoolExternalToolEntity.schoolParameters).toEqual([]); + }); + }); +}); 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 new file mode 100644 index 00000000000..b72ef8df76b --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -0,0 +1,35 @@ +import { Embedded, Entity, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { School } from '@shared/domain/entity/school.entity'; +import { CustomParameterEntryEntity } from '../../common/entity'; +import { ExternalToolEntity } from '../../external-tool/entity'; + +export interface ISchoolExternalToolProperties { + tool: ExternalToolEntity; + school: School; + schoolParameters?: CustomParameterEntryEntity[]; + toolVersion: number; +} + +@Entity({ tableName: 'school_external_tools' }) +export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { + @ManyToOne() + tool: ExternalToolEntity; + + @ManyToOne(() => School, { eager: true }) + school: School; + + @Embedded(() => CustomParameterEntryEntity, { array: true }) + schoolParameters: CustomParameterEntryEntity[]; + + @Property() + toolVersion: number; + + constructor(props: ISchoolExternalToolProperties) { + super(); + this.tool = props.tool; + this.school = props.school; + this.schoolParameters = props.schoolParameters ?? []; + this.toolVersion = props.toolVersion; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts index 37481037c1f..002f2c571cc 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts @@ -1,13 +1,13 @@ import { SchoolExternalToolRequestMapper } from './school-external-tool-request.mapper'; -import { SchoolExternalTool } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; describe('SchoolExternalToolRequestMapper', () => { const mapper: SchoolExternalToolRequestMapper = new SchoolExternalToolRequestMapper(); - describe('mapSchoolExternalToolRequest is called', () => { + describe('mapSchoolExternalToolRequest', () => { describe('when SchoolExternalToolPostParams is given', () => { - it('should return an schoolExternalTool', () => { + const setup = () => { const param: CustomParameterEntryParam = { name: 'name', value: 'value', @@ -19,9 +19,18 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: [param], }; - const schoolExternalTool: SchoolExternalTool = mapper.mapSchoolExternalToolRequest(params); + return { + param, + params, + }; + }; + + it('should return an schoolExternalTool', () => { + const { param, params } = setup(); + + const schoolExternalToolDto: SchoolExternalToolDto = mapper.mapSchoolExternalToolRequest(params); - expect(schoolExternalTool).toEqual({ + expect(schoolExternalToolDto).toEqual({ toolId: params.toolId, parameters: [{ name: param.name, value: param.value }], schoolId: params.schoolId, @@ -31,7 +40,7 @@ describe('SchoolExternalToolRequestMapper', () => { }); describe('when parameters are not given', () => { - it('should return an schoolExternalTool without parameter', () => { + const setup = () => { const params: SchoolExternalToolPostParams = { toolId: 'toolId', version: 1, @@ -39,9 +48,17 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: undefined, }; - const schoolExternalTool: SchoolExternalTool = mapper.mapSchoolExternalToolRequest(params); + return { + params, + }; + }; + + it('should return an schoolExternalTool without parameter', () => { + const { params } = setup(); + + const schoolExternalToolDto: SchoolExternalToolDto = mapper.mapSchoolExternalToolRequest(params); - expect(schoolExternalTool).toEqual({ + expect(schoolExternalToolDto).toEqual({ toolId: params.toolId, parameters: [], schoolId: params.schoolId, diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts index 737ea1952c6..6617e4ec7fd 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { CustomParameterEntryDO } from '@shared/domain'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; -import { SchoolExternalTool } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; +import { CustomParameterEntry } from '../../common/domain'; @Injectable() export class SchoolExternalToolRequestMapper { - mapSchoolExternalToolRequest(request: SchoolExternalToolPostParams): SchoolExternalTool { + mapSchoolExternalToolRequest(request: SchoolExternalToolPostParams): SchoolExternalToolDto { return { toolId: request.toolId, schoolId: request.schoolId, @@ -16,7 +16,7 @@ export class SchoolExternalToolRequestMapper { private mapRequestToCustomParameterEntryDO( customParameterParams: CustomParameterEntryParam[] - ): CustomParameterEntryDO[] { + ): CustomParameterEntry[] { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts index 84c758ce0ba..916445770e3 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts @@ -1,15 +1,8 @@ -import { schoolExternalToolDOFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; -import { ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; -import { externalToolDOFactory } from '@shared/testing'; -import { SchoolExternalToolResponseMapper } from './school-external-tool-response.mapper'; -import { AvailableToolsForContext } from '../../external-tool/uc'; -import { - SchoolExternalToolResponse, - SchoolExternalToolSearchListResponse, - SchoolToolConfigurationEntryResponse, - SchoolToolConfigurationListResponse, -} from '../controller/dto'; +import { schoolExternalToolFactory } from '@shared/testing/factory'; import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; +import { SchoolExternalToolResponse, SchoolExternalToolSearchListResponse } from '../controller/dto'; +import { SchoolExternalTool } from '../domain'; +import { SchoolExternalToolResponseMapper } from './school-external-tool-response.mapper'; describe('SchoolExternalToolResponseMapper', () => { let mapper: SchoolExternalToolResponseMapper; @@ -18,20 +11,7 @@ describe('SchoolExternalToolResponseMapper', () => { mapper = new SchoolExternalToolResponseMapper(); }); - describe('mapToSearchListResponse is called', () => { - const setup = () => { - const do1: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const do2: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - - const dos: SchoolExternalToolDO[] = [do1, do2]; - - return { - dos, - do1, - do2, - }; - }; - + describe('mapToSearchListResponse', () => { it('should return a schoolExternalToolResponse', () => { const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse([]); @@ -39,9 +19,22 @@ describe('SchoolExternalToolResponseMapper', () => { }); describe('when parameter are given', () => { + const setup = () => { + const do1: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const do2: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + do2.status = undefined; + + const dos: SchoolExternalTool[] = [do1, do2]; + + return { + dos, + do1, + do2, + }; + }; + it('should map domain objects correctly', () => { const { dos, do1, do2 } = setup(); - do2.status = undefined; const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse(dos); @@ -81,12 +74,24 @@ describe('SchoolExternalToolResponseMapper', () => { }); describe('when optional parameter are missing', () => { - it('should set defaults', () => { - const { dos, do1 } = setup(); + const setup = () => { + const do1: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); do1.id = undefined; do1.name = undefined; do1.status = undefined; + const do2: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + + const dos: SchoolExternalTool[] = [do1, do2]; + + return { + dos, + }; + }; + + it('should set defaults', () => { + const { dos } = setup(); + const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse(dos); expect(response.data[0]).toEqual( @@ -99,45 +104,4 @@ describe('SchoolExternalToolResponseMapper', () => { }); }); }); - - describe('mapExternalToolDOsToSchoolToolConfigurationListResponse is called', () => { - describe('when mapping from ExternalToolDOs and SchoolToolIds to SchoolToolConfigurationListResponse', () => { - const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ - id: 'toolId', - name: 'toolName', - logoUrl: 'logoUrl', - }); - - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ id: 'SchoolToolId' }); - - const availableTools: AvailableToolsForContext[] = [ - { externalTool: externalToolDO, schoolExternalTool: schoolExternalToolDO }, - ]; - - const expectedResponse: SchoolToolConfigurationEntryResponse = new SchoolToolConfigurationEntryResponse( - { - id: 'toolId', - name: 'toolName', - logoUrl: 'logoUrl', - }, - 'SchoolToolId' - ); - - return { - availableTools, - expectedResponse, - }; - }; - - it('should map from ExternalToolDOs and SchoolToolids to SchoolToolConfigurationListResponse', () => { - const { availableTools, expectedResponse } = setup(); - - const result: SchoolToolConfigurationListResponse = - SchoolExternalToolResponseMapper.mapExternalToolDOsToSchoolToolConfigurationListResponse(availableTools); - - expect(result.data).toEqual(expect.arrayContaining([expectedResponse])); - }); - }); - }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts index ec48ba7776e..10ee706dd81 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts @@ -1,19 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { - SchoolExternalToolDO, - CustomParameterEntryDO, - CustomParameterEntry, - ToolConfigurationStatus, -} from '@shared/domain'; +import { CustomParameterEntry } from '../../common/domain'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; import { CustomParameterEntryResponse, SchoolExternalToolResponse, SchoolExternalToolSearchListResponse, - SchoolToolConfigurationEntryResponse, - SchoolToolConfigurationListResponse, } from '../controller/dto'; -import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; -import { AvailableToolsForContext } from '../../external-tool/uc'; +import { SchoolExternalTool } from '../domain'; export const statusMapping: Record = { [ToolConfigurationStatus.LATEST]: ToolConfigurationStatusResponse.LATEST, @@ -23,56 +17,30 @@ export const statusMapping: Record + mapToSearchListResponse(externalTools: SchoolExternalTool[]): SchoolExternalToolSearchListResponse { + const responses: SchoolExternalToolResponse[] = externalTools.map((toolDO: SchoolExternalTool) => this.mapToSchoolExternalToolResponse(toolDO) ); return new SchoolExternalToolSearchListResponse(responses); } - mapToSchoolExternalToolResponse(schoolExternalToolDO: SchoolExternalToolDO): SchoolExternalToolResponse { + mapToSchoolExternalToolResponse(schoolExternalTool: SchoolExternalTool): SchoolExternalToolResponse { return { - id: schoolExternalToolDO.id ?? '', - name: schoolExternalToolDO.name ?? '', - toolId: schoolExternalToolDO.toolId, - schoolId: schoolExternalToolDO.schoolId, - parameters: this.mapToCustomParameterEntryResponse(schoolExternalToolDO.parameters), - toolVersion: schoolExternalToolDO.toolVersion, - status: schoolExternalToolDO.status - ? statusMapping[schoolExternalToolDO.status] + id: schoolExternalTool.id ?? '', + name: schoolExternalTool.name ?? '', + toolId: schoolExternalTool.toolId, + schoolId: schoolExternalTool.schoolId, + parameters: this.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), + toolVersion: schoolExternalTool.toolVersion, + status: schoolExternalTool.status + ? statusMapping[schoolExternalTool.status] : ToolConfigurationStatusResponse.UNKNOWN, }; } - static mapExternalToolDOsToSchoolToolConfigurationListResponse( - availableToolsForContext: AvailableToolsForContext[] - ): SchoolToolConfigurationListResponse { - return new SchoolToolConfigurationListResponse( - this.mapExternalToolDOsToSchoolToolConfigurationResponses(availableToolsForContext) - ); - } - - private static mapExternalToolDOsToSchoolToolConfigurationResponses( - availableToolsForContext: AvailableToolsForContext[] - ): SchoolToolConfigurationEntryResponse[] { - const mapped = availableToolsForContext.map( - (tool: AvailableToolsForContext) => - new SchoolToolConfigurationEntryResponse( - { - id: tool.externalTool.id || '', - name: tool.externalTool.name, - logoUrl: tool.externalTool.logoUrl, - }, - tool.schoolExternalTool.id as string - ) - ); - - return mapped; - } - - private mapToCustomParameterEntryResponse(entries: CustomParameterEntryDO[]): CustomParameterEntryResponse[] { + private mapToCustomParameterEntryResponse(entries: CustomParameterEntry[]): CustomParameterEntryResponse[] { return entries.map( - (entry: CustomParameterEntry): CustomParameterEntryDO => + (entry: CustomParameterEntry): CustomParameterEntry => new CustomParameterEntryResponse({ name: entry.name, value: entry.value, diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts index dad59732c10..7ca001675b0 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts @@ -1,9 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; -import { externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing/factory/domainobject/tool'; +import { externalToolFactory, schoolExternalToolFactory } from '@shared/testing/factory/domainobject/tool'; import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; describe('SchoolExternalToolValidationService', () => { @@ -39,60 +40,60 @@ describe('SchoolExternalToolValidationService', () => { describe('validate', () => { const setup = ( - externalToolDoMock?: Partial, - schoolExternalToolDoMock?: Partial + externalToolDoMock?: Partial, + schoolExternalToolDoMock?: Partial ) => { - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ - ...schoolExternalToolDOFactory.buildWithId(), + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + ...schoolExternalToolFactory.buildWithId(), ...schoolExternalToolDoMock, }); - const externalToolDO: ExternalToolDO = new ExternalToolDO({ - ...externalToolDOFactory.buildWithId(), + const externalTool: ExternalTool = new ExternalTool({ + ...externalToolFactory.buildWithId(), ...externalToolDoMock, }); - externalToolService.findExternalToolById.mockResolvedValue(externalToolDO); - const schoolExternalToolId = schoolExternalToolDO.id as string; + externalToolService.findExternalToolById.mockResolvedValue(externalTool); + const schoolExternalToolId = schoolExternalTool.id as string; return { - schoolExternalToolDO, - externalToolDO, + schoolExternalTool, + ExternalTool, schoolExternalToolId, }; }; describe('when schoolExternalTool is given', () => { it('should call externalToolService.findExternalToolById', async () => { - const { schoolExternalToolDO } = setup(); + const { schoolExternalTool } = setup(); - await service.validate(schoolExternalToolDO); + await service.validate(schoolExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(schoolExternalToolDO.toolId); + expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); it('should call commonToolValidationService.checkForDuplicateParameters', async () => { - const { schoolExternalToolDO } = setup(); + const { schoolExternalTool } = setup(); - await service.validate(schoolExternalToolDO); + await service.validate(schoolExternalTool); - expect(commonToolValidationService.checkForDuplicateParameters).toHaveBeenCalledWith(schoolExternalToolDO); + expect(commonToolValidationService.checkForDuplicateParameters).toHaveBeenCalledWith(schoolExternalTool); }); it('should call commonToolValidationService.checkCustomParameterEntries', async () => { - const { schoolExternalToolDO } = setup(); + const { schoolExternalTool } = setup(); - await service.validate(schoolExternalToolDO); + await service.validate(schoolExternalTool); expect(commonToolValidationService.checkCustomParameterEntries).toHaveBeenCalledWith( expect.anything(), - schoolExternalToolDO + schoolExternalTool ); }); }); describe('when version of externalTool and schoolExternalTool are different', () => { it('should throw error', async () => { - const { schoolExternalToolDO } = setup({ version: 8383 }, { toolVersion: 1337 }); + const { schoolExternalTool } = setup({ version: 8383 }, { toolVersion: 1337 }); - const func = () => service.validate(schoolExternalToolDO); + const func = () => service.validate(schoolExternalTool); await expect(func()).rejects.toThrowError('tool_version_mismatch:'); }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts index a7d7b3879eb..8cc50097d5f 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; import { CommonToolValidationService } from '../../common/service'; +import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../domain'; @Injectable() export class SchoolExternalToolValidationService { @@ -11,16 +12,16 @@ export class SchoolExternalToolValidationService { private readonly commonToolValidationService: CommonToolValidationService ) {} - async validate(schoolExternalToolDO: SchoolExternalToolDO): Promise { - this.commonToolValidationService.checkForDuplicateParameters(schoolExternalToolDO); + async validate(schoolExternalTool: SchoolExternalTool): Promise { + this.commonToolValidationService.checkForDuplicateParameters(schoolExternalTool); - const loadedExternalTool: ExternalToolDO = await this.externalToolService.findExternalToolById( - schoolExternalToolDO.toolId + const loadedExternalTool: ExternalTool = await this.externalToolService.findExternalToolById( + schoolExternalTool.toolId ); - this.checkVersionMatch(schoolExternalToolDO.toolVersion, loadedExternalTool.version); + this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); - this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, schoolExternalToolDO); + this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, schoolExternalTool); } private checkVersionMatch(schoolExternalToolVersion: number, externalToolVersion: number): void { diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts index 476544eea71..7c4031ef0b7 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts @@ -1,11 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalToolDO, SchoolExternalToolDO, ToolConfigurationStatus } from '@shared/domain'; import { SchoolExternalToolRepo } from '@shared/repo'; -import { externalToolDOFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; -import { schoolExternalToolDOFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; +import { externalToolFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; +import { schoolExternalToolFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalToolService } from './school-external-tool.service'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../domain'; +import { ToolConfigurationStatus } from '../../common/enum'; describe('SchoolExternalToolService', () => { let module: TestingModule; @@ -35,8 +37,8 @@ describe('SchoolExternalToolService', () => { }); const setup = () => { - const schoolExternalTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build(); - const externalTool: ExternalToolDO = externalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); @@ -47,7 +49,7 @@ describe('SchoolExternalToolService', () => { }; }; - describe('findSchoolExternalTools is called', () => { + describe('findSchoolExternalTools', () => { describe('when called with query', () => { it('should call repo with query', async () => { const { schoolExternalTool } = setup(); @@ -57,18 +59,18 @@ describe('SchoolExternalToolService', () => { expect(schoolExternalToolRepo.find).toHaveBeenCalledWith({ schoolId: schoolExternalTool.schoolId }); }); - it('should return schoolExternalToolDO array', async () => { + it('should return schoolExternalTool array', async () => { const { schoolExternalTool } = setup(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool, schoolExternalTool]); - const result: SchoolExternalToolDO[] = await service.findSchoolExternalTools(schoolExternalTool); + const result: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); expect(Array.isArray(result)).toBe(true); }); }); }); - describe('enrichDataFromExternalTool is called', () => { + describe('enrichDataFromExternalTool', () => { it('should call the externalToolService', async () => { const { schoolExternalTool } = setup(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); @@ -86,9 +88,7 @@ describe('SchoolExternalToolService', () => { schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); externalToolService.findExternalToolById.mockResolvedValue(externalTool); - const schoolExternalToolDOs: SchoolExternalToolDO[] = await service.findSchoolExternalTools( - schoolExternalTool - ); + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.OUTDATED); }); @@ -102,9 +102,7 @@ describe('SchoolExternalToolService', () => { schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); externalToolService.findExternalToolById.mockResolvedValue(externalTool); - const schoolExternalToolDOs: SchoolExternalToolDO[] = await service.findSchoolExternalTools( - schoolExternalTool - ); + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.LATEST); }); @@ -118,9 +116,7 @@ describe('SchoolExternalToolService', () => { schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); externalToolService.findExternalToolById.mockResolvedValue(externalTool); - const schoolExternalToolDOs: SchoolExternalToolDO[] = await service.findSchoolExternalTools( - schoolExternalTool - ); + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.LATEST); }); @@ -128,7 +124,7 @@ describe('SchoolExternalToolService', () => { }); }); - describe('deleteSchoolExternalToolById is called', () => { + describe('deleteSchoolExternalToolById', () => { describe('when schoolExternalToolId is given', () => { it('should call the schoolExternalToolRepo', async () => { const { schoolExternalToolId } = setup(); @@ -140,7 +136,7 @@ describe('SchoolExternalToolService', () => { }); }); - describe('getSchoolExternalToolById is called', () => { + describe('getSchoolExternalToolById', () => { describe('when schoolExternalToolId is given', () => { it('should call schoolExternalToolRepo.findById', async () => { const { schoolExternalToolId } = setup(); @@ -152,7 +148,7 @@ describe('SchoolExternalToolService', () => { }); }); - describe('saveSchoolExternalTool is called', () => { + describe('saveSchoolExternalTool', () => { describe('when schoolExternalTool is given', () => { it('should call schoolExternalToolRepo.save', async () => { const { schoolExternalTool } = setup(); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index b90ff9349a5..9ee30d70db6 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts @@ -1,8 +1,11 @@ import { Injectable } from '@nestjs/common'; import { SchoolExternalToolRepo } from '@shared/repo'; -import { SchoolExternalToolDO, ExternalToolDO, ToolConfigurationStatus, EntityId } from '@shared/domain'; +import { EntityId } from '@shared/domain'; import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { ToolConfigurationStatus } from '../../common/enum'; @Injectable() export class SchoolExternalToolService { @@ -11,45 +14,43 @@ export class SchoolExternalToolService { private readonly externalToolService: ExternalToolService ) {} - async getSchoolExternalToolById(schoolExternalToolId: EntityId): Promise { - const schoolExternalTool = await this.schoolExternalToolRepo.findById(schoolExternalToolId); + async getSchoolExternalToolById(schoolExternalToolId: EntityId): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.findById(schoolExternalToolId); return schoolExternalTool; } - async findSchoolExternalTools(query: SchoolExternalToolQuery): Promise { - let schoolExternalToolDOs: SchoolExternalToolDO[] = await this.schoolExternalToolRepo.find({ + async findSchoolExternalTools(query: SchoolExternalToolQuery): Promise { + let schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolRepo.find({ schoolId: query.schoolId, }); - schoolExternalToolDOs = await this.enrichWithDataFromExternalTools(schoolExternalToolDOs); + schoolExternalTools = await this.enrichWithDataFromExternalTools(schoolExternalTools); - return schoolExternalToolDOs; + return schoolExternalTools; } - private async enrichWithDataFromExternalTools(tools: SchoolExternalToolDO[]): Promise { - const enrichedTools: SchoolExternalToolDO[] = await Promise.all( - tools.map( - async (tool: SchoolExternalToolDO): Promise => this.enrichDataFromExternalTool(tool) - ) + private async enrichWithDataFromExternalTools(tools: SchoolExternalTool[]): Promise { + const enrichedTools: SchoolExternalTool[] = await Promise.all( + tools.map(async (tool: SchoolExternalTool): Promise => this.enrichDataFromExternalTool(tool)) ); return enrichedTools; } - private async enrichDataFromExternalTool(tool: SchoolExternalToolDO): Promise { - const externalToolDO: ExternalToolDO = await this.externalToolService.findExternalToolById(tool.toolId); - const status: ToolConfigurationStatus = this.determineStatus(tool, externalToolDO); - const schoolExternalTool: SchoolExternalToolDO = new SchoolExternalToolDO({ + private async enrichDataFromExternalTool(tool: SchoolExternalTool): Promise { + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(tool.toolId); + const status: ToolConfigurationStatus = this.determineStatus(tool, externalTool); + const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ ...tool, status, - name: externalToolDO.name, + name: externalTool.name, }); return schoolExternalTool; } - private determineStatus(tool: SchoolExternalToolDO, externalToolDO: ExternalToolDO): ToolConfigurationStatus { - if (externalToolDO.version <= tool.toolVersion) { + private determineStatus(tool: SchoolExternalTool, externalTool: ExternalTool): ToolConfigurationStatus { + if (externalTool.version <= tool.toolVersion) { return ToolConfigurationStatus.LATEST; } @@ -60,8 +61,8 @@ export class SchoolExternalToolService { await this.schoolExternalToolRepo.deleteById(schoolExternalToolId); } - async saveSchoolExternalTool(schoolExternalTool: SchoolExternalToolDO): Promise { - let createdSchoolExternalTool: SchoolExternalToolDO = await this.schoolExternalToolRepo.save(schoolExternalTool); + async saveSchoolExternalTool(schoolExternalTool: SchoolExternalTool): Promise { + let createdSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.save(schoolExternalTool); createdSchoolExternalTool = await this.enrichDataFromExternalTool(createdSchoolExternalTool); return createdSchoolExternalTool; } diff --git a/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts b/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts index 3be5f77d8f8..caa6510ce2e 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts @@ -1,6 +1,6 @@ -import { SchoolExternalToolProps } from '@shared/domain/domainobject/tool/school-external-tool.do'; +import { SchoolExternalToolProps } from '../../domain'; -export type SchoolExternalTool = SchoolExternalToolProps; +export type SchoolExternalToolDto = SchoolExternalToolProps; export type SchoolExternalToolQueryInput = { schoolId?: string; diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts index 68d53732342..c0daab13cff 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts @@ -1,31 +1,29 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityId, Permission, User, SchoolExternalToolDO } from '@shared/domain'; -import { setupEntities, userFactory, schoolExternalToolDOFactory } from '@shared/testing'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { EntityId, Permission, User } from '@shared/domain'; +import { schoolExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; import { SchoolExternalToolUc } from './school-external-tool.uc'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; +import { SchoolExternalTool } from '../domain'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('SchoolExternalToolUc', () => { let module: TestingModule; let uc: SchoolExternalToolUc; - let authorizationService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; let schoolExternalToolValidationService: DeepMocked; + let toolPermissionHelper: DeepMocked; beforeAll(async () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ SchoolExternalToolUc, - { - provide: AuthorizationService, - useValue: createMock(), - }, { provide: SchoolExternalToolService, useValue: createMock(), @@ -38,14 +36,18 @@ describe('SchoolExternalToolUc', () => { provide: SchoolExternalToolValidationService, useValue: createMock(), }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, ], }).compile(); uc = module.get(SchoolExternalToolUc); - authorizationService = module.get(AuthorizationService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); + toolPermissionHelper = module.get(ToolPermissionHelper); }); afterAll(async () => { @@ -56,100 +58,129 @@ describe('SchoolExternalToolUc', () => { jest.resetAllMocks(); }); - const setup = () => { - const tool: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const user: User = userFactory.buildWithId(); + describe('findSchoolExternalTools', () => { + describe('when checks permission', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); - return { - user, - userId: user.id, - tool, - schoolId: tool.schoolId, - schoolExternalToolId: tool.id as EntityId, - }; - }; + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); + + return { + user, + tool, + }; + }; - describe('findSchoolExternalTools is called', () => { - describe('when checks permission', () => { it('should check the permissions of the user', async () => { - const { user, tool, schoolId } = setup(); + const { user, tool } = setup(); await uc.findSchoolExternalTools(user.id, tool); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( user.id, - AuthorizableReferenceType.School, - schoolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); - }); - it('should call the service', async () => { - const { user, tool } = setup(); + it('should call the service', async () => { + const { user, tool } = setup(); - await uc.findSchoolExternalTools(user.id, tool); + await uc.findSchoolExternalTools(user.id, tool); - expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ schoolId: tool.schoolId }); + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ schoolId: tool.schoolId }); + }); }); - describe('when query parameters', () => { - describe('are empty', () => { - it('should not call the service', async () => { - const { user } = setup(); - const emptyQuery: SchoolExternalToolQueryInput = {}; + describe('when query parameters are empty', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const emptyQuery: SchoolExternalToolQueryInput = {}; + + return { + emptyQuery, + user, + }; + }; - await uc.findSchoolExternalTools(user.id, emptyQuery); + it('should not call the service', async () => { + const { user, emptyQuery } = setup(); - expect(schoolExternalToolService.findSchoolExternalTools).not.toHaveBeenCalled(); - }); + await uc.findSchoolExternalTools(user.id, emptyQuery); - it('should return a empty array', async () => { - const { user } = setup(); - const emptyQuery: Partial = {}; + expect(schoolExternalToolService.findSchoolExternalTools).not.toHaveBeenCalled(); + }); - const result: SchoolExternalToolDO[] = await uc.findSchoolExternalTools(user.id, emptyQuery); + it('should return a empty array', async () => { + const { user, emptyQuery } = setup(); - expect(result).toEqual([]); - }); + const result: SchoolExternalTool[] = await uc.findSchoolExternalTools(user.id, emptyQuery); + + expect(result).toEqual([]); }); + }); - describe('has schoolId set', () => { - it('should return a schoolExternalToolDO array', async () => { - const { user, tool } = setup(); - schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool, tool]); + describe('when schoolId has been set', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); - const result: SchoolExternalToolDO[] = await uc.findSchoolExternalTools(user.id, tool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool, tool]); - expect(result).toEqual([tool, tool]); - }); + return { + user, + tool, + }; + }; + + it('should return a schoolExternalTool array', async () => { + const { user, tool } = setup(); + + const result: SchoolExternalTool[] = await uc.findSchoolExternalTools(user.id, tool); + + expect(result).toEqual([tool, tool]); }); }); }); - describe('deleteSchoolExternalTool is called', () => { + describe('deleteSchoolExternalTool', () => { describe('when checks permission', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + + return { + user, + tool, + schoolExternalToolId: tool.id as EntityId, + }; + }; + it('should check the permissions of the user', async () => { - const { user, schoolExternalToolId } = setup(); + const { user, tool, schoolExternalToolId } = setup(); await uc.deleteSchoolExternalTool(user.id, schoolExternalToolId); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( user.id, - AuthorizableReferenceType.SchoolExternalTool, - schoolExternalToolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); }); describe('when calls services', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + + return { + userId: user.id, + schoolExternalToolId: tool.id as EntityId, + }; + }; + it('should call the courseExternalToolService', async () => { const { userId, schoolExternalToolId } = setup(); @@ -168,26 +199,42 @@ describe('SchoolExternalToolUc', () => { }); }); - describe('createSchoolExternalTool is called', () => { + describe('createSchoolExternalTool', () => { describe('when checks permission', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + + return { + user, + tool, + }; + }; + it('should check the permissions of the user', async () => { - const { user, tool, schoolId } = setup(); + const { user, tool } = setup(); await uc.createSchoolExternalTool(user.id, tool); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( user.id, - AuthorizableReferenceType.School, - schoolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); }); describe('when userId and schoolExternalTool are given', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + + return { + user, + tool, + }; + }; + it('should call schoolExternalToolValidationService.validate()', async () => { const { user, tool } = setup(); @@ -206,68 +253,89 @@ describe('SchoolExternalToolUc', () => { }); }); - describe('getSchoolExternalTool is called', () => { + describe('getSchoolExternalTool', () => { describe('when checks permission', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(tool); + + return { + user, + tool, + schoolExternalToolId: tool.id as EntityId, + }; + }; + it('should check the permissions of the user', async () => { - const { user, schoolExternalToolId } = setup(); + const { user, schoolExternalToolId, tool } = setup(); await uc.getSchoolExternalTool(user.id, schoolExternalToolId); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( user.id, - AuthorizableReferenceType.SchoolExternalTool, - schoolExternalToolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); }); + describe('when userId and schoolExternalTool are given', () => { - it('should return a schoolExternalToolDO', async () => { - const { user, schoolExternalToolId, tool } = setup(); + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const user: User = userFactory.buildWithId(); schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(tool); - const result: SchoolExternalToolDO = await uc.getSchoolExternalTool(user.id, schoolExternalToolId); + return { + user, + tool, + schoolExternalToolId: tool.id as EntityId, + }; + }; + + it('should return a schoolExternalTool', async () => { + const { user, schoolExternalToolId, tool } = setup(); + + const result: SchoolExternalTool = await uc.getSchoolExternalTool(user.id, schoolExternalToolId); expect(result).toEqual(tool); }); }); }); - describe('updateSchoolExternalTool is called', () => { - const setupUpdate = () => { - const { tool, user } = setup(); - const updatedTool: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ ...tool }); + describe('updateSchoolExternalTool', () => { + const setup = () => { + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const updatedTool: SchoolExternalTool = schoolExternalToolFactory.build({ ...tool }); updatedTool.parameters[0].value = 'updatedValue'; + const user: User = userFactory.buildWithId(); schoolExternalToolService.saveSchoolExternalTool.mockResolvedValue(updatedTool); + return { + user, + userId: user.id, updatedTool, + schoolId: tool.schoolId, schoolExternalToolId: updatedTool.id as EntityId, - user, }; }; it('should check the permissions of the user', async () => { - const { updatedTool, schoolExternalToolId, user } = setupUpdate(); + const { updatedTool, schoolExternalToolId, user } = setup(); await uc.updateSchoolExternalTool(user.id, schoolExternalToolId, updatedTool); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( user.id, - AuthorizableReferenceType.SchoolExternalTool, - schoolExternalToolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } + updatedTool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); it('should call schoolExternalToolValidationService.validate()', async () => { - const { updatedTool, schoolExternalToolId, user } = setupUpdate(); + const { updatedTool, schoolExternalToolId, user } = setup(); await uc.updateSchoolExternalTool(user.id, schoolExternalToolId, updatedTool); @@ -275,21 +343,17 @@ describe('SchoolExternalToolUc', () => { }); it('should call the service to update the tool', async () => { - const { updatedTool, schoolExternalToolId, user } = setupUpdate(); + const { updatedTool, schoolExternalToolId, user } = setup(); await uc.updateSchoolExternalTool(user.id, schoolExternalToolId, updatedTool); expect(schoolExternalToolService.saveSchoolExternalTool).toHaveBeenCalledWith(updatedTool); }); - it('should return a schoolExternalToolDO', async () => { - const { updatedTool, schoolExternalToolId, user } = setupUpdate(); + it('should return a schoolExternalTool', async () => { + const { updatedTool, schoolExternalToolId, user } = setup(); - const result: SchoolExternalToolDO = await uc.updateSchoolExternalTool( - user.id, - schoolExternalToolId, - updatedTool - ); + const result: SchoolExternalTool = await uc.updateSchoolExternalTool(user.id, schoolExternalToolId, updatedTool); expect(result).toEqual(updatedTool); }); diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts index b941e980136..63067c234d7 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts @@ -1,56 +1,68 @@ import { Injectable } from '@nestjs/common'; -import { EntityId, Permission, SchoolExternalToolDO } from '@shared/domain'; -import { Action, AuthorizationService, AuthorizableReferenceType } from '@src/modules/authorization'; +import { EntityId, Permission } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalTool, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; +import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; +import { SchoolExternalTool } from '../domain'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class SchoolExternalToolUc { constructor( - private readonly authorizationService: AuthorizationService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService + private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, + private readonly toolPermissionHelper: ToolPermissionHelper ) {} - async findSchoolExternalTools( - userId: EntityId, - query: SchoolExternalToolQueryInput - ): Promise { - let tools: SchoolExternalToolDO[] = []; + async findSchoolExternalTools(userId: EntityId, query: SchoolExternalToolQueryInput): Promise { + let tools: SchoolExternalTool[] = []; if (query.schoolId) { - await this.ensureSchoolPermission(userId, query.schoolId); tools = await this.schoolExternalToolService.findSchoolExternalTools({ schoolId: query.schoolId }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + + await this.ensureSchoolPermissions(userId, tools, context); } return tools; } async createSchoolExternalTool( userId: EntityId, - schoolExternalTool: SchoolExternalTool - ): Promise { - const schoolExternalToolDO = new SchoolExternalToolDO({ ...schoolExternalTool }); + schoolExternalToolDto: SchoolExternalToolDto + ): Promise { + const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - await this.ensureSchoolPermission(userId, schoolExternalTool.schoolId); - await this.schoolExternalToolValidationService.validate(schoolExternalToolDO); + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + await this.schoolExternalToolValidationService.validate(schoolExternalTool); - const createdSchoolExternalTool: SchoolExternalToolDO = await this.schoolExternalToolService.saveSchoolExternalTool( - schoolExternalToolDO + const createdSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.saveSchoolExternalTool( + schoolExternalTool ); return createdSchoolExternalTool; } - private async ensureSchoolPermission(userId: EntityId, schoolId: EntityId): Promise { - return this.authorizationService.checkPermissionByReferences(userId, AuthorizableReferenceType.School, schoolId, { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - }); + private async ensureSchoolPermissions( + userId: EntityId, + tools: SchoolExternalTool[], + context: AuthorizationContext + ): Promise { + await Promise.all( + tools.map(async (tool: SchoolExternalTool) => + this.toolPermissionHelper.ensureSchoolPermissions(userId, tool, context) + ) + ); } async deleteSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { - await this.ensureSchoolExternalToolPermission(userId, schoolExternalToolId); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + schoolExternalToolId + ); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); await Promise.all([ this.contextExternalToolService.deleteBySchoolExternalToolId(schoolExternalToolId), @@ -58,43 +70,33 @@ export class SchoolExternalToolUc { ]); } - async getSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { - await this.ensureSchoolExternalToolPermission(userId, schoolExternalToolId); - - const schoolExternalTool: SchoolExternalToolDO = await this.schoolExternalToolService.getSchoolExternalToolById( + async getSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( schoolExternalToolId ); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); return schoolExternalTool; } async updateSchoolExternalTool( userId: EntityId, schoolExternalToolId: string, - schoolExternalTool: SchoolExternalTool - ): Promise { - const schoolExternalToolDO = new SchoolExternalToolDO({ ...schoolExternalTool }); + schoolExternalToolDto: SchoolExternalToolDto + ): Promise { + const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - await this.ensureSchoolExternalToolPermission(userId, schoolExternalToolId); - await this.schoolExternalToolValidationService.validate(schoolExternalToolDO); + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + await this.schoolExternalToolValidationService.validate(schoolExternalTool); - const updated: SchoolExternalToolDO = new SchoolExternalToolDO({ - ...schoolExternalTool, + const updated: SchoolExternalTool = new SchoolExternalTool({ + ...schoolExternalToolDto, id: schoolExternalToolId, }); const saved = await this.schoolExternalToolService.saveSchoolExternalTool(updated); return saved; } - - private async ensureSchoolExternalToolPermission(userId: EntityId, schoolExternalToolId: EntityId): Promise { - return this.authorizationService.checkPermissionByReferences( - userId, - AuthorizableReferenceType.SchoolExternalTool, - schoolExternalToolId, - { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_TOOL_ADMIN], - } - ); - } } diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index 396e1e5cbc2..a93420b59da 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -4,20 +4,31 @@ import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@src/modules/authorization'; import { SchoolModule } from '@src/modules/school'; import { UserModule } from '@src/modules/user'; -import { ToolLaunchController } from './tool-launch/controller/tool-launch.controller'; -import { ToolModule } from './tool.module'; -import { ToolConfigurationController, ToolController } from './external-tool/controller'; -import { ToolSchoolController } from './school-external-tool/controller'; import { ToolContextController } from './context-external-tool/controller'; -import { ExternalToolConfigurationUc, ExternalToolUc, ToolReferenceUc } from './external-tool/uc'; +import { ContextExternalToolUc } from './context-external-tool/uc'; +import { ToolConfigurationController, ToolController } from './external-tool/controller'; import { ExternalToolRequestMapper, ExternalToolResponseMapper } from './external-tool/mapper'; -import { SchoolExternalToolUc } from './school-external-tool/uc'; +import { ExternalToolConfigurationUc, ExternalToolUc, ToolReferenceUc } from './external-tool/uc'; +import { ToolSchoolController } from './school-external-tool/controller'; import { SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper } from './school-external-tool/mapper'; -import { ContextExternalToolUc } from './context-external-tool/uc'; +import { SchoolExternalToolUc } from './school-external-tool/uc'; +import { ToolConfigModule } from './tool-config.module'; +import { ToolLaunchController } from './tool-launch/controller/tool-launch.controller'; import { ToolLaunchUc } from './tool-launch/uc'; +import { ToolModule } from './tool.module'; +import { ExternalToolConfigurationService } from './external-tool/service'; +import { CommonToolModule } from './common'; @Module({ - imports: [ToolModule, UserModule, AuthorizationModule, LoggerModule, SchoolModule], + imports: [ + ToolModule, + CommonToolModule, + UserModule, + AuthorizationModule, + LoggerModule, + SchoolModule, + ToolConfigModule, + ], controllers: [ ToolLaunchController, ToolConfigurationController, @@ -29,6 +40,7 @@ import { ToolLaunchUc } from './tool-launch/uc'; LtiToolRepo, ExternalToolUc, ExternalToolConfigurationUc, + ExternalToolConfigurationService, ExternalToolRequestMapper, ExternalToolResponseMapper, SchoolExternalToolUc, diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index ce60925b880..2bd47089286 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -5,11 +5,17 @@ export const ToolFeatures = Symbol('ToolFeatures'); export interface IToolFeatures { ctlToolsTabEnabled: boolean; ltiToolsTabEnabled: boolean; + contextConfigurationEnabled: boolean; + maxExternalToolLogoSizeInBytes: number; + backEndUrl: string; } export default class ToolConfiguration { static toolFeatures: IToolFeatures = { ctlToolsTabEnabled: Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED') as boolean, ltiToolsTabEnabled: Configuration.get('FEATURE_LTI_TOOLS_TAB_ENABLED') as boolean, + contextConfigurationEnabled: Configuration.get('FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED') as boolean, + maxExternalToolLogoSizeInBytes: Configuration.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') as number, + backEndUrl: Configuration.get('PUBLIC_BACKEND_URL') as string, }; } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index a5265ddfbbe..c1d4bd74cef 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -1,61 +1,39 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Course, Permission, School } from '@shared/domain'; import { - ContextExternalTool, - ContextExternalToolType, - Course, - ExternalTool, - Permission, - Role, - School, - SchoolExternalTool, - ToolConfigType, - User, -} from '@shared/domain'; -import { - basicToolConfigDOFactory, - contextExternalToolDOFactory, + basicToolConfigFactory, contextExternalToolFactory, + contextExternalToolEntityFactory, courseFactory, - externalToolFactory, - mapUserToCurrentUser, - roleFactory, - schoolExternalToolFactory, + externalToolEntityFactory, + schoolExternalToolEntityFactory, schoolFactory, - userFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@src/modules/server'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; +import { Response } from 'supertest'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { LaunchRequestMethod } from '../../types'; import { ToolLaunchRequestResponse, ToolLaunchParams } from '../dto'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { ExternalToolEntity } from '../../../external-tool/entity'; +import { ToolConfigType } from '../../../common/enum'; -// TODO Refactor to use api testHelpers describe('ToolLaunchController (API)', () => { let app: INestApplication; let em: EntityManager; let orm: MikroORM; - - let currentUser: ICurrentUser | undefined; + let testApiClient: TestApiClient; const BASE_URL = '/tools/context'; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleRef.createNestApplication(); @@ -63,6 +41,7 @@ describe('ToolLaunchController (API)', () => { em = app.get(EntityManager); orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, BASE_URL); }); afterAll(async () => { @@ -76,40 +55,50 @@ describe('ToolLaunchController (API)', () => { describe('[GET] tools/context/{contextExternalToolId}/launch', () => { describe('when valid data is given', () => { const setup = async () => { - const role: Role = roleFactory.buildWithId({ permissions: [Permission.CONTEXT_TOOL_USER] }); const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ roles: [role], school }); - const course: Course = courseFactory.buildWithId({ school, teachers: [user] }); - currentUser = mapUserToCurrentUser(user); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - config: basicToolConfigDOFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), version: 0, }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalTool.id }; + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; - await em.persistAndFlush([school, user, course, externalTool, schoolExternalTool, contextExternalTool]); + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); em.clear(); - return { params }; + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; }; it('should return a launch response', async () => { - const { params } = await setup(); + const { params, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); - const response: Response = await request(app.getHttpServer()) - .get(`${BASE_URL}/${params.contextExternalToolId}/launch`) - .expect(HttpStatus.OK); + expect(response.statusCode).toEqual(HttpStatus.OK); const body: ToolLaunchRequestResponse = response.body as ToolLaunchRequestResponse; expect(body).toEqual({ @@ -122,42 +111,50 @@ describe('ToolLaunchController (API)', () => { describe('when user wants to launch an outdated tool', () => { const setup = async () => { - const role: Role = roleFactory.buildWithId({ permissions: [Permission.CONTEXT_TOOL_USER] }); const school: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ roles: [role], school }); - const course: Course = courseFactory.buildWithId({ school, teachers: [user] }); - currentUser = mapUserToCurrentUser(user); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - config: basicToolConfigDOFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), version: 1, }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, toolVersion: 0, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, toolVersion: 0, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalTool.id }; + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; - await em.persistAndFlush([school, user, course, externalTool, schoolExternalTool, contextExternalTool]); + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); em.clear(); - return { params }; + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; }; it('should return a bad request', async () => { - const { params } = await setup(); + const { params, loggedInClient } = await setup(); - const response: Response = await request(app.getHttpServer()).get( - `${BASE_URL}/${params.contextExternalToolId}/launch` - ); + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); @@ -165,64 +162,71 @@ describe('ToolLaunchController (API)', () => { describe('when user wants to launch tool from another school', () => { const setup = async () => { - const role: Role = roleFactory.buildWithId({ permissions: [] }); - const toolSchool: School = schoolFactory.buildWithId(); const usersSchool: School = schoolFactory.buildWithId(); - const user: User = userFactory.buildWithId({ roles: [role], school: usersSchool }); - const course: Course = courseFactory.buildWithId({ school: usersSchool, teachers: [user] }); - currentUser = mapUserToCurrentUser(user); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school: usersSchool }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school: usersSchool, teachers: [teacherUser] }); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - config: basicToolConfigDOFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school: toolSchool, }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool: schoolExternalTool, + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalTool.id }; + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ toolSchool, usersSchool, - user, + teacherUser, + teacherAccount, course, - externalTool, - schoolExternalTool, - contextExternalTool, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, ]); em.clear(); - return { params }; + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; }; it('should return forbidden', async () => { - const { params } = await setup(); + const { params, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); - await request(app.getHttpServer()) - .get(`${BASE_URL}/${params.contextExternalToolId}/launch`) - .expect(HttpStatus.FORBIDDEN); + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); }); describe('when user is not authenticated', () => { - it('should return unauthorized', async () => { - const contextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const setup = () => { + const contextExternalTool = contextExternalToolFactory.buildWithId(); const params: ToolLaunchParams = { - contextExternalToolId: contextExternalToolDO.id as string, + contextExternalToolId: contextExternalTool.id as string, }; - currentUser = undefined; - await request(app.getHttpServer()) - .get(`${BASE_URL}/${params.contextExternalToolId}/launch`) - .expect(HttpStatus.UNAUTHORIZED); + return { params }; + }; + + it('should return unauthorized', async () => { + const { params } = setup(); + + const response = await testApiClient.get(`${params.contextExternalToolId}/launch`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.spec.ts index 4e989bdf8c6..3a903976f06 100644 --- a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.spec.ts @@ -1,13 +1,14 @@ -import { ContextExternalToolDO, CustomParameterDO } from '@shared/domain'; -import { contextExternalToolDOFactory, customParameterDOFactory } from '@shared/testing'; +import { contextExternalToolFactory, customParameterFactory } from '@shared/testing'; import { MissingToolParameterValueLoggableException } from './missing-tool-parameter-value.loggable-exception'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { CustomParameter } from '../../common/domain'; describe('MissingToolParameterValueLoggableException', () => { describe('getLogMessage', () => { const setup = () => { - const contextExternalTool: ContextExternalToolDO = contextExternalToolDOFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - const customParameters: CustomParameterDO[] = customParameterDOFactory.buildList(2); + const customParameters: CustomParameter[] = customParameterFactory.buildList(2); const exception = new MissingToolParameterValueLoggableException(contextExternalTool, customParameters); diff --git a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts index d0a7093a06f..e72af82d85a 100644 --- a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts @@ -1,12 +1,13 @@ import { HttpStatus } from '@nestjs/common'; import { BusinessError } from '@shared/common'; -import { ContextExternalToolDO, CustomParameterDO } from '@shared/domain'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { CustomParameter } from '../../common/domain'; export class MissingToolParameterValueLoggableException extends BusinessError implements Loggable { constructor( - private readonly contextExternalTool: ContextExternalToolDO, - private readonly parameters: CustomParameterDO[] + private readonly contextExternalTool: ContextExternalTool, + private readonly parameters: CustomParameter[] ) { super( { diff --git a/apps/server/src/modules/tool/tool-launch/error/parameter-type-not-implemented.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/parameter-type-not-implemented.loggable-exception.spec.ts index 1d57e4b6e77..f9f4208fa13 100644 --- a/apps/server/src/modules/tool/tool-launch/error/parameter-type-not-implemented.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/parameter-type-not-implemented.loggable-exception.spec.ts @@ -1,4 +1,4 @@ -import { CustomParameterType } from '@shared/domain'; +import { CustomParameterType } from '../../common/enum'; import { ParameterTypeNotImplementedLoggableException } from './parameter-type-not-implemented.loggable-exception'; describe('ParameterNotImplementedLoggableException', () => { diff --git a/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.spec.ts b/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.spec.ts index 8d33a015d05..04d6ee23ae0 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.spec.ts @@ -1,6 +1,6 @@ import { RoleName } from '@shared/domain'; -import { LtiRole } from '../../common/interface'; import { LtiRoleMapper } from './lti-role.mapper'; +import { LtiRole } from '../../common/enum'; describe('LtiRoleMapper', () => { describe('mapRolesToLtiRoles', () => { diff --git a/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.ts b/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.ts index 49e7eedfb9d..6f3d24c9e38 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/lti-role.mapper.ts @@ -1,5 +1,5 @@ import { RoleName } from '@shared/domain'; -import { LtiRole } from '../../common/interface'; +import { LtiRole } from '../../common/enum'; const RoleMapping: Partial> = { [RoleName.USER]: LtiRole.LEARNER, diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts index 73ec83fde2e..611e83e36f1 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts @@ -1,7 +1,7 @@ -import { CustomParameterLocation, ToolConfigType } from '@shared/domain'; import { ToolLaunchRequestResponse } from '../controller/dto'; import { LaunchRequestMethod, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchMapper } from './tool-launch.mapper'; +import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; describe('ToolLaunchMapper', () => { describe('mapToParameterLocation', () => { diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts index 7b8d814a195..7fd0370d589 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts @@ -1,6 +1,6 @@ -import { CustomParameterLocation, ToolConfigType } from '@shared/domain'; import { PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchRequestResponse } from '../controller/dto'; +import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; const customToParameterLocationMapping: Record = { [CustomParameterLocation.PATH]: PropertyLocation.PATH, diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts index 3ee0701b241..10f0e19e639 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts @@ -2,30 +2,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { - ContextExternalToolDO, - Course, - CustomParameterEntryDO, - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - EntityId, - ExternalToolDO, - SchoolDO, - SchoolExternalToolDO, -} from '@shared/domain'; +import { Course, EntityId, SchoolDO } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; import { - contextExternalToolDOFactory, + contextExternalToolFactory, courseFactory, - customParameterDOFactory, - externalToolDOFactory, + customParameterFactory, + externalToolFactory, schoolDOFactory, - schoolExternalToolDOFactory, + schoolExternalToolFactory, setupEntities, } from '@shared/testing'; import { SchoolService } from '@src/modules/school'; -import { ToolContextType } from '../../../common/interface'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; import { LaunchRequestMethod, @@ -37,6 +25,16 @@ import { } from '../../types'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; +import { + CustomParameterLocation, + CustomParameterScope, + CustomParameterType, + ToolContextType, +} from '../../../common/enum'; +import { ExternalTool } from '../../../external-tool/domain'; +import { CustomParameterEntry } from '../../../common/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; const concreteConfigParameter: PropertyData = { location: PropertyLocation.QUERY, @@ -110,51 +108,51 @@ describe('AbstractLaunchStrategy', () => { const schoolId: string = new ObjectId().toHexString(); // External Tool - const globalCustomParameter = customParameterDOFactory.build({ + const globalCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.PATH, default: 'value', name: 'globalParam', type: CustomParameterType.STRING, }); - const schoolCustomParameter = customParameterDOFactory.build({ + const schoolCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.SCHOOL, location: CustomParameterLocation.BODY, name: 'schoolParam', type: CustomParameterType.BOOLEAN, }); - const contextCustomParameter = customParameterDOFactory.build({ + const contextCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.CONTEXT, location: CustomParameterLocation.QUERY, name: 'contextParam', type: CustomParameterType.NUMBER, }); - const autoSchoolIdCustomParameter = customParameterDOFactory.build({ + const autoSchoolIdCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoSchoolIdParam', type: CustomParameterType.AUTO_SCHOOLID, }); - const autoCourseIdCustomParameter = customParameterDOFactory.build({ + const autoCourseIdCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoCourseIdParam', type: CustomParameterType.AUTO_CONTEXTID, }); - const autoCourseNameCustomParameter = customParameterDOFactory.build({ + const autoCourseNameCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoCourseNameParam', type: CustomParameterType.AUTO_CONTEXTNAME, }); - const autoSchoolNumberCustomParameter = customParameterDOFactory.build({ + const autoSchoolNumberCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoSchoolNumberParam', type: CustomParameterType.AUTO_SCHOOLNUMBER, }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [ globalCustomParameter, schoolCustomParameter, @@ -167,21 +165,21 @@ describe('AbstractLaunchStrategy', () => { }); // School External Tool - const schoolParameterEntry: CustomParameterEntryDO = new CustomParameterEntryDO({ + const schoolParameterEntry: CustomParameterEntry = new CustomParameterEntry({ name: schoolCustomParameter.name, value: 'true', }); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [schoolParameterEntry], schoolId, }); // Context External Tool - const contextParameterEntry: CustomParameterEntryDO = new CustomParameterEntryDO({ + const contextParameterEntry: CustomParameterEntry = new CustomParameterEntry({ name: contextCustomParameter.name, value: 'anyValue2', }); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ parameters: [contextParameterEntry], }); @@ -197,7 +195,7 @@ describe('AbstractLaunchStrategy', () => { { name: 'testName', }, - contextExternalToolDO.contextRef.id + contextExternalTool.contextRef.id ); schoolService.getSchoolById.mockResolvedValue(school); @@ -222,9 +220,9 @@ describe('AbstractLaunchStrategy', () => { autoSchoolNumberCustomParameter, schoolParameterEntry, contextParameterEntry, - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, course, school, sortFn, @@ -241,23 +239,23 @@ describe('AbstractLaunchStrategy', () => { autoCourseNameCustomParameter, autoSchoolNumberCustomParameter, schoolParameterEntry, - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, course, school, sortFn, } = setup(); const result: ToolLaunchData = await launchStrategy.createLaunchData('userId', { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); result.properties = result.properties.sort(sortFn); expect(result).toEqual({ - baseUrl: externalToolDO.config.baseUrl, + baseUrl: externalTool.config.baseUrl, type: ToolLaunchDataType.BASIC, openNewTab: false, properties: [ @@ -308,36 +306,36 @@ describe('AbstractLaunchStrategy', () => { describe('when no parameters were defined', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [], }); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [], }); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ parameters: [], }); return { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; }; it('should return a ToolLaunchData with no custom parameters', async () => { - const { externalToolDO, schoolExternalToolDO, contextExternalToolDO } = setup(); + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const result: ToolLaunchData = await launchStrategy.createLaunchData('userId', { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); expect(result).toEqual({ - baseUrl: externalToolDO.config.baseUrl, + baseUrl: externalTool.config.baseUrl, type: ToolLaunchDataType.BASIC, openNewTab: false, properties: [ @@ -353,21 +351,21 @@ describe('AbstractLaunchStrategy', () => { describe('when a parameter has no value, but is required', () => { const setup = () => { - const autoSchoolNumberCustomParameter = customParameterDOFactory.build({ + const autoSchoolNumberCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoSchoolNumberParam', type: CustomParameterType.AUTO_SCHOOLNUMBER, }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [autoSchoolNumberCustomParameter], }); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [], }); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ parameters: [], }); @@ -378,20 +376,20 @@ describe('AbstractLaunchStrategy', () => { schoolService.getSchoolById.mockResolvedValue(school); return { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; }; it('should throw a MissingToolParameterValueLoggableException', async () => { - const { externalToolDO, schoolExternalToolDO, contextExternalToolDO } = setup(); + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const func = async () => launchStrategy.createLaunchData('userId', { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); await expect(func).rejects.toThrow(MissingToolParameterValueLoggableException); @@ -400,39 +398,39 @@ describe('AbstractLaunchStrategy', () => { describe('when a parameter type is not implemented ', () => { const setup = () => { - const customParameterWithUnknownType = customParameterDOFactory.build({ + const customParameterWithUnknownType = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'unknownTypeParam', type: 'unknownType' as unknown as CustomParameterType, }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [customParameterWithUnknownType], }); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [], }); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ parameters: [], }); return { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; }; it('should throw a ParameterNotImplementedLoggableException', async () => { - const { externalToolDO, schoolExternalToolDO, contextExternalToolDO } = setup(); + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const func = async () => launchStrategy.createLaunchData('userId', { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); await expect(func).rejects.toThrow(ParameterTypeNotImplementedLoggableException); @@ -441,21 +439,21 @@ describe('AbstractLaunchStrategy', () => { describe('when a lookup for a context name is not implemented', () => { const setup = () => { - const customParameterWithUnknownType = customParameterDOFactory.build({ + const customParameterWithUnknownType = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, name: 'autoContextNameParam', type: CustomParameterType.AUTO_CONTEXTNAME, }); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [customParameterWithUnknownType], }); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build({ + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ parameters: [], }); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ contextRef: { id: new ObjectId().toHexString(), type: 'unknownContext' as unknown as ToolContextType, @@ -464,20 +462,20 @@ describe('AbstractLaunchStrategy', () => { }); return { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; }; it('should throw a ParameterNotImplementedLoggableException', async () => { - const { externalToolDO, schoolExternalToolDO, contextExternalToolDO } = setup(); + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const func = async () => launchStrategy.createLaunchData('userId', { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); await expect(func).rejects.toThrow(ParameterTypeNotImplementedLoggableException); diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts index c5332b5857c..b2fe6f94446 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts @@ -1,33 +1,30 @@ import { Injectable } from '@nestjs/common'; +import { Course, EntityId, SchoolDO } from '@shared/domain'; +import { CourseRepo } from '@shared/repo'; +import { SchoolService } from '@src/modules/school'; +import { URLSearchParams } from 'url'; import { - ContextExternalToolDO, - Course, - CustomParameterDO, - CustomParameterEntryDO, CustomParameterLocation, CustomParameterScope, CustomParameterType, - EntityId, - ExternalToolDO, - SchoolDO, - SchoolExternalToolDO, -} from '@shared/domain'; -import { CourseRepo } from '@shared/repo'; -import { SchoolService } from '@src/modules/school'; -import { URLSearchParams } from 'url'; -import { ToolContextType } from '../../../common/interface'; + ToolContextType, +} from '../../../common/enum'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; import { ToolLaunchMapper } from '../../mapper'; import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchData, ToolLaunchRequest } from '../../types'; import { IToolLaunchParams } from './tool-launch-params.interface'; import { IToolLaunchStrategy } from './tool-launch-strategy.interface'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { CustomParameter, CustomParameterEntry } from '../../../common/domain'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ExternalTool } from '../../../external-tool/domain'; @Injectable() export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { constructor(private readonly schoolService: SchoolService, private readonly courseRepo: CourseRepo) {} public async createLaunchData(userId: EntityId, data: IToolLaunchParams): Promise { - const launchData: ToolLaunchData = this.buildToolLaunchDataFromExternalTool(data.externalToolDO); + const launchData: ToolLaunchData = this.buildToolLaunchDataFromExternalTool(data.externalTool); const launchDataProperties: PropertyData[] = await this.buildToolLaunchDataFromTools(data); const additionalLaunchDataProperties: PropertyData[] = await this.buildToolLaunchDataFromConcreteConfig( @@ -114,12 +111,12 @@ export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { url.pathname = filledPathParams.join('/'); } - private buildToolLaunchDataFromExternalTool(externalToolDO: ExternalToolDO): ToolLaunchData { + private buildToolLaunchDataFromExternalTool(externalTool: ExternalTool): ToolLaunchData { const launchData = new ToolLaunchData({ - baseUrl: externalToolDO.config.baseUrl, - type: ToolLaunchMapper.mapToToolLaunchDataType(externalToolDO.config.type), + baseUrl: externalTool.config.baseUrl, + type: ToolLaunchMapper.mapToToolLaunchDataType(externalTool.config.type), properties: [], - openNewTab: externalToolDO.openNewTab, + openNewTab: externalTool.openNewTab, }); return launchData; @@ -127,41 +124,41 @@ export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { private async buildToolLaunchDataFromTools(data: IToolLaunchParams): Promise { const propertyData: PropertyData[] = []; - const { externalToolDO, schoolExternalToolDO, contextExternalToolDO } = data; - const customParameters = externalToolDO.parameters || []; + const { externalTool, schoolExternalTool, contextExternalTool } = data; + const customParameters = externalTool.parameters || []; - const scopes: { scope: CustomParameterScope; params: CustomParameterEntryDO[] }[] = [ + const scopes: { scope: CustomParameterScope; params: CustomParameterEntry[] }[] = [ { scope: CustomParameterScope.GLOBAL, params: customParameters }, - { scope: CustomParameterScope.SCHOOL, params: schoolExternalToolDO.parameters || [] }, - { scope: CustomParameterScope.CONTEXT, params: contextExternalToolDO.parameters || [] }, + { scope: CustomParameterScope.SCHOOL, params: schoolExternalTool.parameters || [] }, + { scope: CustomParameterScope.CONTEXT, params: contextExternalTool.parameters || [] }, ]; - await this.addParameters(propertyData, customParameters, scopes, schoolExternalToolDO, contextExternalToolDO); + await this.addParameters(propertyData, customParameters, scopes, schoolExternalTool, contextExternalTool); return propertyData; } private async addParameters( propertyData: PropertyData[], - customParameterDOs: CustomParameterDO[], - scopes: { scope: CustomParameterScope; params: CustomParameterEntryDO[] }[], - schoolExternalToolDO: SchoolExternalToolDO, - contextExternalToolDO: ContextExternalToolDO + customParameterDOs: CustomParameter[], + scopes: { scope: CustomParameterScope; params: CustomParameterEntry[] }[], + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool ): Promise { await Promise.all( scopes.map(async ({ scope, params }): Promise => { - const parameterNames: string[] = params.map((parameter: CustomParameterEntryDO) => parameter.name); + const parameterNames: string[] = params.map((parameter: CustomParameterEntry) => parameter.name); - const parametersToInclude: CustomParameterDO[] = customParameterDOs.filter( - (parameter: CustomParameterDO) => parameter.scope === scope && parameterNames.includes(parameter.name) + const parametersToInclude: CustomParameter[] = customParameterDOs.filter( + (parameter: CustomParameter) => parameter.scope === scope && parameterNames.includes(parameter.name) ); await this.handleParametersToInclude( propertyData, parametersToInclude, params, - schoolExternalToolDO, - contextExternalToolDO + schoolExternalTool, + contextExternalTool ); }) ); @@ -169,24 +166,24 @@ export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { private async handleParametersToInclude( propertyData: PropertyData[], - parametersToInclude: CustomParameterDO[], - params: CustomParameterEntryDO[], - schoolExternalToolDO: SchoolExternalToolDO, - contextExternalToolDO: ContextExternalToolDO + parametersToInclude: CustomParameter[], + params: CustomParameterEntry[], + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool ): Promise { - const missingParameters: CustomParameterDO[] = []; + const missingParameters: CustomParameter[] = []; await Promise.all( parametersToInclude.map(async (parameter): Promise => { - const matchingParameter: CustomParameterEntryDO | undefined = params.find( - (param: CustomParameterEntryDO) => param.name === parameter.name + const matchingParameter: CustomParameterEntry | undefined = params.find( + (param: CustomParameterEntry) => param.name === parameter.name ); const value: string | undefined = await this.getParameterValue( parameter, matchingParameter, - schoolExternalToolDO, - contextExternalToolDO + schoolExternalTool, + contextExternalTool ); if (value !== undefined) { @@ -200,36 +197,36 @@ export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { ); if (missingParameters.length > 0) { - throw new MissingToolParameterValueLoggableException(contextExternalToolDO, missingParameters); + throw new MissingToolParameterValueLoggableException(contextExternalTool, missingParameters); } } private async getParameterValue( - customParameter: CustomParameterDO, - matchingParameterEntry: CustomParameterEntryDO | undefined, - schoolExternalToolDO: SchoolExternalToolDO, - contextExternalToolDO: ContextExternalToolDO + customParameter: CustomParameter, + matchingParameterEntry: CustomParameterEntry | undefined, + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool ): Promise { switch (customParameter.type) { case CustomParameterType.AUTO_SCHOOLID: { - return schoolExternalToolDO.schoolId; + return schoolExternalTool.schoolId; } case CustomParameterType.AUTO_CONTEXTID: { - return contextExternalToolDO.contextRef.id; + return contextExternalTool.contextRef.id; } case CustomParameterType.AUTO_CONTEXTNAME: { - if (contextExternalToolDO.contextRef.type === ToolContextType.COURSE) { - const course: Course = await this.courseRepo.findById(contextExternalToolDO.contextRef.id); + if (contextExternalTool.contextRef.type === ToolContextType.COURSE) { + const course: Course = await this.courseRepo.findById(contextExternalTool.contextRef.id); return course.name; } throw new ParameterTypeNotImplementedLoggableException( - `${customParameter.type}/${contextExternalToolDO.contextRef.type as string}` + `${customParameter.type}/${contextExternalTool.contextRef.type as string}` ); } case CustomParameterType.AUTO_SCHOOLNUMBER: { - const school: SchoolDO = await this.schoolService.getSchoolById(schoolExternalToolDO.schoolId); + const school: SchoolDO = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); return school.officialSchoolNumber; } diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/basic-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/basic-tool-launch.strategy.spec.ts index 4b616723e68..4aa1314ce41 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/basic-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/basic-tool-launch.strategy.spec.ts @@ -1,12 +1,14 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO, ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; -import { contextExternalToolDOFactory, externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; import { SchoolService } from '@src/modules/school'; import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; import { BasicToolLaunchStrategy } from './basic-tool-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; +import { ExternalTool } from '../../../external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; describe('BasicToolLaunchStrategy', () => { let module: TestingModule; @@ -100,14 +102,14 @@ describe('BasicToolLaunchStrategy', () => { describe('buildToolLaunchDataFromConcreteConfig', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; return { data }; diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts index 1e80b230683..5a58fe373b5 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts @@ -1,21 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { - ContextExternalToolDO, - ExternalToolDO, - LtiMessageType, - LtiPrivacyPermission, - Pseudonym, - RoleName, - SchoolExternalToolDO, - UserDO, -} from '@shared/domain'; +import { Pseudonym, RoleName, UserDO } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; import { - contextExternalToolDOFactory, - externalToolDOFactory, - schoolExternalToolDOFactory, + contextExternalToolFactory, + externalToolFactory, + schoolExternalToolFactory, userDoFactory, } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; @@ -24,7 +15,10 @@ import { SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; import { Authorization } from 'oauth-1.0a'; -import { LtiRole, ToolContextType } from '../../../common/interface'; +import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ExternalTool } from '../../../external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; @@ -87,23 +81,25 @@ describe('Lti11ToolLaunchStrategy', () => { const mockSecret = 'mockSecret'; const ltiMessageType = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; const resourceLinkId = 'resourceLinkId'; + const launchPresentationLocale = 'de-DE'; - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withLti11Config({ key: mockKey, secret: mockSecret, lti_message_type: ltiMessageType, privacy_permission: LtiPrivacyPermission.PUBLIC, resource_link_id: resourceLinkId, + launch_presentation_locale: launchPresentationLocale, }) .buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; const user: UserDO = userDoFactory.buildWithId({ @@ -128,6 +124,7 @@ describe('Lti11ToolLaunchStrategy', () => { mockSecret, ltiMessageType, resourceLinkId, + launchPresentationLocale, }; }; @@ -145,7 +142,7 @@ describe('Lti11ToolLaunchStrategy', () => { }); it('should contain mandatory lti attributes', async () => { - const { data, ltiMessageType, resourceLinkId } = setup(); + const { data, ltiMessageType, resourceLinkId, launchPresentationLocale } = setup(); const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); @@ -165,7 +162,7 @@ describe('Lti11ToolLaunchStrategy', () => { }), new PropertyData({ name: 'launch_presentation_locale', - value: 'de-DE', + value: launchPresentationLocale, location: PropertyLocation.BODY, }), new PropertyData({ @@ -180,7 +177,7 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when no resource link id is available', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withLti11Config({ key: 'mockKey', secret: 'mockSecret', @@ -189,17 +186,17 @@ describe('Lti11ToolLaunchStrategy', () => { resource_link_id: undefined, }) .buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const contextId: string = new ObjectId().toHexString(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ contextRef: { id: contextId, type: ToolContextType.COURSE }, }); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; const user: UserDO = userDoFactory.buildWithId(); @@ -231,7 +228,7 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when lti privacyPermission is name', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withLti11Config({ key: 'mockKey', secret: 'mockSecret', @@ -240,13 +237,13 @@ describe('Lti11ToolLaunchStrategy', () => { resource_link_id: 'resourceLinkId', }) .buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; const userId: string = new ObjectId().toHexString(); @@ -280,7 +277,7 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when lti privacyPermission is email', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withLti11Config({ key: 'mockKey', secret: 'mockSecret', @@ -289,13 +286,13 @@ describe('Lti11ToolLaunchStrategy', () => { resource_link_id: 'resourceLinkId', }) .buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; const userId: string = new ObjectId().toHexString(); @@ -331,7 +328,7 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when lti privacyPermission is pseudonymous', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory + const externalTool: ExternalTool = externalToolFactory .withLti11Config({ key: 'mockKey', secret: 'mockSecret', @@ -340,18 +337,18 @@ describe('Lti11ToolLaunchStrategy', () => { resource_link_id: 'resourceLinkId', }) .buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; const user: UserDO = userDoFactory.buildWithId(); - const pseudonym: Pseudonym = pseudonymFactory.buildWithId(); + const pseudonym: Pseudonym = pseudonymFactory.build(); userService.findById.mockResolvedValue(user); pseudonymService.findOrCreatePseudonym.mockResolvedValue(pseudonym); @@ -377,14 +374,14 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when tool config is not lti', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; return { diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts index d5de797802a..7038765f06b 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts @@ -1,17 +1,18 @@ import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, ExternalToolDO, LtiPrivacyPermission, Pseudonym, RoleName, UserDO } from '@shared/domain'; +import { EntityId, LtiPrivacyPermission, Pseudonym, RoleName, UserDO } from '@shared/domain'; import { RoleReference } from '@shared/domain/domainobject'; import { CourseRepo } from '@shared/repo'; import { PseudonymService } from '@src/modules/pseudonym'; import { SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; import { Authorization } from 'oauth-1.0a'; +import { LtiRole } from '../../../common/enum'; +import { ExternalTool } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; -import { LtiRole } from '../../../common/interface'; @Injectable() export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { @@ -30,10 +31,10 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { userId: EntityId, data: IToolLaunchParams ): Promise { - const { config } = data.externalToolDO; - const contextId: EntityId = data.contextExternalToolDO.contextRef.id; + const { config } = data.externalTool; + const contextId: EntityId = data.contextExternalTool.contextRef.id; - if (!ExternalToolDO.isLti11Config(config)) { + if (!ExternalTool.isLti11Config(config)) { throw new UnprocessableEntityException( `Unable to build LTI 1.1 launch data. Tool configuration is of type ${config.type}. Expected "lti11"` ); @@ -62,7 +63,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { }), new PropertyData({ name: 'launch_presentation_locale', - value: 'de-DE', + value: config.launch_presentation_locale, location: PropertyLocation.BODY, }), new PropertyData({ @@ -95,7 +96,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { } if (config.privacy_permission === LtiPrivacyPermission.PSEUDONYMOUS) { - const pseudonym: Pseudonym = await this.pseudonymService.findOrCreatePseudonym(user, data.externalToolDO); + const pseudonym: Pseudonym = await this.pseudonymService.findOrCreatePseudonym(user, data.externalTool); additionalProperties.push( new PropertyData({ diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts index 4c88f61e148..021e9c93ba3 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts @@ -1,12 +1,14 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO, ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; import { CourseRepo } from '@shared/repo'; -import { contextExternalToolDOFactory, externalToolDOFactory, schoolExternalToolDOFactory } from '@shared/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; import { SchoolService } from '@src/modules/school'; import { LaunchRequestMethod, PropertyData } from '../../types'; import { OAuth2ToolLaunchStrategy } from './oauth2-tool-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; +import { ExternalTool } from '../../../external-tool/domain'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; describe('OAuth2ToolLaunchStrategy', () => { let module: TestingModule; @@ -43,14 +45,14 @@ describe('OAuth2ToolLaunchStrategy', () => { describe('buildToolLaunchDataFromConcreteConfig', () => { describe('when always', () => { const setup = () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.build(); - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.build(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: IToolLaunchParams = { - contextExternalToolDO, - schoolExternalToolDO, - externalToolDO, + contextExternalTool, + schoolExternalTool, + externalTool, }; return { data }; diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/tool-launch-params.interface.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/tool-launch-params.interface.ts index 8433016027d..a6d1b75d9cf 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/tool-launch-params.interface.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/tool-launch-params.interface.ts @@ -1,9 +1,11 @@ -import { ContextExternalToolDO, ExternalToolDO, SchoolExternalToolDO } from '@shared/domain'; +import { ExternalTool } from '../../../external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; export interface IToolLaunchParams { - externalToolDO: ExternalToolDO; + externalTool: ExternalTool; - schoolExternalToolDO: SchoolExternalToolDO; + schoolExternalTool: SchoolExternalTool; - contextExternalToolDO: ContextExternalToolDO; + contextExternalTool: ContextExternalTool; } diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 4fe20276213..3be1df798fe 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -2,19 +2,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { - BasicToolConfigDO, - ExternalToolDO, - SchoolExternalToolDO, - ToolConfigType, - ToolConfigurationStatus, -} from '@shared/domain'; -import { ContextExternalToolDO } from '@shared/domain/domainobject/tool/context-external-tool.do'; -import { - basicToolConfigDOFactory, - contextExternalToolDOFactory, - externalToolDOFactory, - schoolExternalToolDOFactory, + basicToolConfigFactory, + contextExternalToolFactory, + externalToolFactory, + schoolExternalToolFactory, } from '@shared/testing'; +import { ContextExternalTool } from '../../context-external-tool/domain'; import { LaunchRequestMethod, ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, @@ -27,6 +20,9 @@ import { ToolStatusOutdatedLoggableException } from '../error'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ExternalToolService } from '../../external-tool/service'; import { CommonToolService } from '../../common/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { BasicToolConfig, ExternalTool } from '../../external-tool/domain'; +import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; describe('ToolLaunchService', () => { let module: TestingModule; @@ -86,12 +82,12 @@ describe('ToolLaunchService', () => { describe('getLaunchData', () => { describe('when the tool config type is BASIC', () => { const setup = () => { - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory - .withSchoolExternalToolRef(schoolExternalToolDO.id as string) + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) .build(); - const basicToolConfigDO: BasicToolConfigDO = basicToolConfigDOFactory.build(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ config: basicToolConfigDO, }); @@ -103,13 +99,13 @@ describe('ToolLaunchService', () => { }); const launchParams: IToolLaunchParams = { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalToolDO); - externalToolService.findExternalToolById.mockResolvedValue(externalToolDO); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); @@ -122,7 +118,7 @@ describe('ToolLaunchService', () => { it('should return launchData', async () => { const { launchParams, launchDataDO } = setup(); - const result: ToolLaunchData = await service.getLaunchData('userId', launchParams.contextExternalToolDO); + const result: ToolLaunchData = await service.getLaunchData('userId', launchParams.contextExternalTool); expect(result).toEqual(launchDataDO); }); @@ -130,7 +126,7 @@ describe('ToolLaunchService', () => { it('should call basicToolLaunchStrategy with given launchParams ', async () => { const { launchParams } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalToolDO); + await service.getLaunchData('userId', launchParams.contextExternalTool); expect(basicToolLaunchStrategy.createLaunchData).toHaveBeenCalledWith('userId', launchParams); }); @@ -138,39 +134,39 @@ describe('ToolLaunchService', () => { it('should call getSchoolExternalToolById', async () => { const { launchParams } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalToolDO); + await service.getLaunchData('userId', launchParams.contextExternalTool); expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( - launchParams.schoolExternalToolDO.id + launchParams.schoolExternalTool.id ); }); it('should call findExternalToolById', async () => { const { launchParams } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalToolDO); + await service.getLaunchData('userId', launchParams.contextExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalToolDO.toolId); + expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); }); }); describe('when the tool config type is unknown', () => { const setup = () => { - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory - .withSchoolExternalToolRef(schoolExternalToolDO.id as string) + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) .build(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build(); - externalToolDO.config.type = 'unknown' as ToolConfigType; + const externalTool: ExternalTool = externalToolFactory.build(); + externalTool.config.type = 'unknown' as ToolConfigType; const launchParams: IToolLaunchParams = { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalToolDO); - externalToolService.findExternalToolById.mockResolvedValue(externalToolDO); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValue(externalTool); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); return { @@ -181,7 +177,7 @@ describe('ToolLaunchService', () => { it('should throw InternalServerErrorException for unknown tool config type', async () => { const { launchParams } = setup(); - await expect(service.getLaunchData('userId', launchParams.contextExternalToolDO)).rejects.toThrow( + await expect(service.getLaunchData('userId', launchParams.contextExternalTool)).rejects.toThrow( new InternalServerErrorException('Unknown tool config type') ); }); @@ -190,13 +186,13 @@ describe('ToolLaunchService', () => { const { launchParams } = setup(); try { - await service.getLaunchData('userId', launchParams.contextExternalToolDO); + await service.getLaunchData('userId', launchParams.contextExternalTool); } catch (exception) { // Do nothing } expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( - launchParams.schoolExternalToolDO.id + launchParams.schoolExternalTool.id ); }); @@ -204,23 +200,23 @@ describe('ToolLaunchService', () => { const { launchParams } = setup(); try { - await service.getLaunchData('userId', launchParams.contextExternalToolDO); + await service.getLaunchData('userId', launchParams.contextExternalTool); } catch (exception) { // Do nothing } - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalToolDO.toolId); + expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); }); }); describe('when tool configuration status is not LATEST', () => { const setup = () => { - const schoolExternalToolDO: SchoolExternalToolDO = schoolExternalToolDOFactory.buildWithId(); - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory - .withSchoolExternalToolRef(schoolExternalToolDO.id as string) + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) .build(); - const basicToolConfigDO: BasicToolConfigDO = basicToolConfigDOFactory.build(); - const externalToolDO: ExternalToolDO = externalToolDOFactory.build({ + const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ config: basicToolConfigDO, }); @@ -232,15 +228,15 @@ describe('ToolLaunchService', () => { }); const launchParams: IToolLaunchParams = { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }; const userId = 'userId'; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalToolDO); - externalToolService.findExternalToolById.mockResolvedValue(externalToolDO); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.OUTDATED); @@ -248,14 +244,14 @@ describe('ToolLaunchService', () => { launchDataDO, launchParams, userId, - contextExternalToolId: contextExternalToolDO.id as string, + contextExternalToolId: contextExternalTool.id as string, }; }; it('should throw ToolStatusOutdatedLoggableException', async () => { const { launchParams, userId, contextExternalToolId } = setup(); - const func = () => service.getLaunchData(userId, launchParams.contextExternalToolDO); + const func = () => service.getLaunchData(userId, launchParams.contextExternalTool); await expect(func).rejects.toThrow(new ToolStatusOutdatedLoggableException(userId, contextExternalToolId)); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index eabd7cbe7d2..3321e782f09 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -1,12 +1,5 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { - ContextExternalToolDO, - EntityId, - ExternalToolDO, - SchoolExternalToolDO, - ToolConfigurationStatus, -} from '@shared/domain'; -import { ToolConfigType } from '../../common/interface'; +import { EntityId } from '@shared/domain'; import { CommonToolService } from '../../common/service'; import { ToolLaunchMapper } from '../mapper'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; @@ -19,6 +12,10 @@ import { import { ToolStatusOutdatedLoggableException } from '../error'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ExternalToolService } from '../../external-tool/service'; +import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; @Injectable() export class ToolLaunchService { @@ -51,23 +48,23 @@ export class ToolLaunchService { return launchRequest; } - async getLaunchData(userId: EntityId, contextExternalToolDO: ContextExternalToolDO): Promise { - const schoolExternalToolId: EntityId = contextExternalToolDO.schoolToolRef.schoolToolId; + async getLaunchData(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { + const schoolExternalToolId: EntityId = contextExternalTool.schoolToolRef.schoolToolId; - const { externalToolDO, schoolExternalToolDO } = await this.loadToolHierarchy(schoolExternalToolId); + const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - this.isToolStatusLatestOrThrow(userId, externalToolDO, schoolExternalToolDO, contextExternalToolDO); + this.isToolStatusLatestOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); - const strategy: IToolLaunchStrategy | undefined = this.strategies.get(externalToolDO.config.type); + const strategy: IToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); if (!strategy) { throw new InternalServerErrorException('Unknown tool config type'); } const launchData: ToolLaunchData = await strategy.createLaunchData(userId, { - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO, + externalTool, + schoolExternalTool, + contextExternalTool, }); return launchData; @@ -75,34 +72,32 @@ export class ToolLaunchService { private async loadToolHierarchy( schoolExternalToolId: string - ): Promise<{ schoolExternalToolDO: SchoolExternalToolDO; externalToolDO: ExternalToolDO }> { - const schoolExternalToolDO: SchoolExternalToolDO = await this.schoolExternalToolService.getSchoolExternalToolById( + ): Promise<{ schoolExternalTool: SchoolExternalTool; externalTool: ExternalTool }> { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( schoolExternalToolId ); - const externalToolDO: ExternalToolDO = await this.externalToolService.findExternalToolById( - schoolExternalToolDO.toolId - ); + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); return { - schoolExternalToolDO, - externalToolDO, + schoolExternalTool, + externalTool, }; } private isToolStatusLatestOrThrow( userId: EntityId, - externalToolDO: ExternalToolDO, - schoolExternalToolDO: SchoolExternalToolDO, - contextExternalToolDO: ContextExternalToolDO + externalTool: ExternalTool, + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool ): void { const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( - externalToolDO, - schoolExternalToolDO, - contextExternalToolDO + externalTool, + schoolExternalTool, + contextExternalTool ); if (status !== ToolConfigurationStatus.LATEST) { - throw new ToolStatusOutdatedLoggableException(userId, contextExternalToolDO.id ?? ''); + throw new ToolStatusOutdatedLoggableException(userId, contextExternalTool.id ?? ''); } } } diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts index 3b41b1b2e29..a0b31b9c321 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts @@ -1,11 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolDO } from '@shared/domain'; -import { contextExternalToolDOFactory } from '@shared/testing'; +import { contextExternalToolFactory } from '@shared/testing'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; import { ContextExternalToolService } from '../../context-external-tool/service'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ToolLaunchUc', () => { let module: TestingModule; @@ -13,6 +14,7 @@ describe('ToolLaunchUc', () => { let toolLaunchService: DeepMocked; let contextExternalToolService: DeepMocked; + let toolPermissionHelper: DeepMocked; beforeEach(async () => { module = await Test.createTestingModule({ @@ -26,12 +28,17 @@ describe('ToolLaunchUc', () => { provide: ContextExternalToolService, useValue: createMock(), }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, ], }).compile(); uc = module.get(ToolLaunchUc); toolLaunchService = module.get(ToolLaunchService); contextExternalToolService = module.get(ContextExternalToolService); + toolPermissionHelper = module.get(ToolPermissionHelper); }); afterAll(async () => { @@ -45,7 +52,7 @@ describe('ToolLaunchUc', () => { describe('getToolLaunchRequest', () => { const setup = () => { const contextExternalToolId = 'contextExternalToolId'; - const contextExternalToolDO: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ id: contextExternalToolId, }); const toolLaunchData: ToolLaunchData = new ToolLaunchData({ @@ -60,7 +67,7 @@ describe('ToolLaunchUc', () => { return { userId, contextExternalToolId, - contextExternalToolDO, + contextExternalTool, toolLaunchData, }; }; @@ -74,19 +81,19 @@ describe('ToolLaunchUc', () => { }); it('should call service to get data', async () => { - const { userId, contextExternalToolId, contextExternalToolDO } = setup(); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalToolDO); + const { userId, contextExternalToolId, contextExternalTool } = setup(); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); + contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); await uc.getToolLaunchRequest(userId, contextExternalToolId); - expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(userId, contextExternalToolDO); + expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(userId, contextExternalTool); }); it('should call service to generate launch request', async () => { - const { userId, contextExternalToolId, contextExternalToolDO, toolLaunchData } = setup(); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalToolDO); + const { userId, contextExternalToolId, contextExternalTool, toolLaunchData } = setup(); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); + contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); toolLaunchService.getLaunchData.mockResolvedValue(toolLaunchData); @@ -96,9 +103,9 @@ describe('ToolLaunchUc', () => { }); it('should return launch request', async () => { - const { userId, contextExternalToolId, toolLaunchData, contextExternalToolDO } = setup(); - contextExternalToolService.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalToolDO); + const { userId, contextExternalToolId, toolLaunchData, contextExternalTool } = setup(); + toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); + contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); toolLaunchService.getLaunchData.mockResolvedValue(toolLaunchData); const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(userId, contextExternalToolId); diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts index 4912fafb781..c397ae1d1af 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts @@ -1,27 +1,29 @@ import { Injectable } from '@nestjs/common'; -import { ContextExternalToolDO, EntityId, Permission } from '@shared/domain'; -import { Action } from '@src/modules/authorization'; +import { EntityId, Permission } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; import { ContextExternalToolService } from '../../context-external-tool/service'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ToolLaunchUc { constructor( private readonly toolLaunchService: ToolLaunchService, - private readonly contextExternalToolService: ContextExternalToolService + private readonly contextExternalToolService: ContextExternalToolService, + private readonly toolPermissionHelper: ToolPermissionHelper ) {} async getToolLaunchRequest(userId: EntityId, contextExternalToolId: EntityId): Promise { - const contextExternalToolDO: ContextExternalToolDO = - await this.contextExternalToolService.getContextExternalToolById(contextExternalToolId); + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( + contextExternalToolId + ); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); - await this.contextExternalToolService.ensureContextPermissions(userId, contextExternalToolDO, { - requiredPermissions: [Permission.CONTEXT_TOOL_USER], - action: Action.read, - }); + await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); - const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData(userId, contextExternalToolDO); + const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData(userId, contextExternalTool); const launchRequest: ToolLaunchRequest = this.toolLaunchService.generateLaunchRequest(toolLaunchData); return launchRequest; diff --git a/apps/server/src/modules/tool/tool.module.ts b/apps/server/src/modules/tool/tool.module.ts index e32b304f787..91a19c5c995 100644 --- a/apps/server/src/modules/tool/tool.module.ts +++ b/apps/server/src/modules/tool/tool.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { ContextExternalToolModule } from './context-external-tool'; import { SchoolExternalToolModule } from './school-external-tool'; import { ExternalToolModule } from './external-tool'; @@ -10,7 +10,7 @@ import { ToolConfigModule } from './tool-config.module'; @Module({ imports: [ ToolConfigModule, - CommonToolModule, + forwardRef(() => CommonToolModule), ExternalToolModule, SchoolExternalToolModule, ContextExternalToolModule, diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts index 3c41a709587..386a2e874b8 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts @@ -3,6 +3,8 @@ import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; import { AccountService } from '@src/modules/account/services/account.service'; import { AuthorizationService } from '@src/modules/authorization'; import { SchoolService } from '@src/modules/school'; +import { LoggerModule } from '@src/core/logger'; +import { ConfigModule } from '@nestjs/config'; import { UserImportUc } from '../uc/user-import.uc'; import { ImportUserController } from './import-user.controller'; @@ -16,6 +18,7 @@ describe('ImportUserController', () => { beforeAll(async () => { module = await Test.createTestingModule({ + imports: [LoggerModule, ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, ignoreEnvVars: true })], providers: [ UserImportUc, { diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts new file mode 100644 index 00000000000..d1ee0b00597 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -0,0 +1,6 @@ +export * from './user-migration-not-enable.loggable'; +export * from './school-in-user-migration-start.loggable'; +export * from './school-in-user-migration-end.loggable'; +export * from './school-id-does-not-match-with-user-school-id.loggable'; +export * from './migration-is-not-completed.loggable'; +export * from './migration-may-be-completed.loggable'; diff --git a/apps/server/src/modules/user-import/loggable/migration-is-not-completed.loggable.ts b/apps/server/src/modules/user-import/loggable/migration-is-not-completed.loggable.ts new file mode 100644 index 00000000000..a3816bb176f --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/migration-is-not-completed.loggable.ts @@ -0,0 +1,14 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class MigrationMayNotBeCompleted implements Loggable { + constructor(private readonly inUserMigration?: boolean) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'The migration may not be yet complete or the school may not be in maintenance mode', + data: { + inUserMigration: this.inUserMigration, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/migration-may-be-completed.loggable.ts b/apps/server/src/modules/user-import/loggable/migration-may-be-completed.loggable.ts new file mode 100644 index 00000000000..52277bc6517 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/migration-may-be-completed.loggable.ts @@ -0,0 +1,14 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class MigrationMayBeCompleted implements Loggable { + constructor(private readonly inUserMigration?: boolean) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'The migration may have already been completed or the school may not yet be in maintenance mode', + data: { + inUserMigration: this.inUserMigration, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/school-id-does-not-match-with-user-school-id.loggable.ts b/apps/server/src/modules/user-import/loggable/school-id-does-not-match-with-user-school-id.loggable.ts new file mode 100644 index 00000000000..b72d56f92a6 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/school-id-does-not-match-with-user-school-id.loggable.ts @@ -0,0 +1,21 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { EntityId } from '@shared/domain'; + +export class SchoolIdDoesNotMatchWithUserSchoolId implements Loggable { + constructor( + private readonly userMatchSchoolId: string, + private readonly importUserSchoolId: string, + private readonly schoolId?: EntityId + ) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'School ID does not match with user school ID or with imported user school ID', + data: { + userMatchSchoolId: this.userMatchSchoolId, + importUserId: this.importUserSchoolId, + schoolId: this.schoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/school-in-user-migration-end.loggable.ts b/apps/server/src/modules/user-import/loggable/school-in-user-migration-end.loggable.ts new file mode 100644 index 00000000000..39378256a07 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/school-in-user-migration-end.loggable.ts @@ -0,0 +1,14 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchoolInUserMigrationEndLoggable implements Loggable { + constructor(private readonly schoolName: string) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Migration for school is completed', + data: { + schoolName: this.schoolName, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/school-in-user-migration-start.loggable.ts b/apps/server/src/modules/user-import/loggable/school-in-user-migration-start.loggable.ts new file mode 100644 index 00000000000..eda5ce944bc --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/school-in-user-migration-start.loggable.ts @@ -0,0 +1,21 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { EntityId } from '@shared/domain'; + +export class SchoolInUserMigrationStartLoggable implements Loggable { + constructor( + private readonly userId: EntityId, + private readonly schoolName: string, + private readonly useCentralLdap: boolean + ) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'The school administrator started the migration for his school.', + data: { + currentUserId: this.userId, + schoolName: this.schoolName, + centralLdap: this.useCentralLdap, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/user-migration-not-enable.loggable.ts b/apps/server/src/modules/user-import/loggable/user-migration-not-enable.loggable.ts new file mode 100644 index 00000000000..2d3de69bfbc --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-not-enable.loggable.ts @@ -0,0 +1,9 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserMigrationIsNotEnabled implements Loggable { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Feature flag of user migration may be disable or the school is not an LDAP pilot', + }; + } +} diff --git a/apps/server/src/modules/user-import/uc/ldap-user-migration.error.ts b/apps/server/src/modules/user-import/uc/ldap-user-migration.error.ts index 7512289377f..8f772ce4deb 100644 --- a/apps/server/src/modules/user-import/uc/ldap-user-migration.error.ts +++ b/apps/server/src/modules/user-import/uc/ldap-user-migration.error.ts @@ -1,19 +1,38 @@ import { BadRequestException, HttpExceptionOptions } from '@nestjs/common'; +import { ErrorLogMessage, LogMessage, Loggable, ValidationErrorLogMessage } from '@src/core/logger'; export class LdapUserMigrationException extends BadRequestException {} -export class LdapAlreadyPersistedException extends LdapUserMigrationException { +export class LdapAlreadyPersistedException extends LdapUserMigrationException implements Loggable { constructor(descriptionOrOptions?: string | HttpExceptionOptions) { super('ldapAlreadyPersisted', descriptionOrOptions); } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'LDAP is already Persisted', + }; + } } -export class MissingSchoolNumberException extends LdapUserMigrationException { +export class MissingSchoolNumberException extends LdapUserMigrationException implements Loggable { constructor(descriptionOrOptions?: string | HttpExceptionOptions) { - super('ldapAlreadyPersisted', descriptionOrOptions); + super('LDAP migration Exception', descriptionOrOptions); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'The school is missing a official school number', + }; } } -export class MigrationAlreadyActivatedException extends LdapUserMigrationException { +export class MigrationAlreadyActivatedException extends LdapUserMigrationException implements Loggable { constructor(descriptionOrOptions?: string | HttpExceptionOptions) { - super('ldapAlreadyPersisted', descriptionOrOptions); + super('LDAP migration Exception', descriptionOrOptions); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Migration is already activated for this school', + }; } } 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 781926fb9ae..66ce2c32fef 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 @@ -21,6 +21,8 @@ import { federalStateFactory, importUserFactory, schoolFactory, userFactory } fr import { systemFactory } from '@shared/testing/factory/system.factory'; import { AccountService } from '@src/modules/account/services/account.service'; import { AuthorizationService } from '@src/modules/authorization'; +import { LoggerModule } from '@src/core/logger'; +import { ConfigModule } from '@nestjs/config'; import { SchoolService } from '../../school'; import { LdapAlreadyPersistedException, @@ -43,7 +45,11 @@ describe('[ImportUserModule]', () => { beforeAll(async () => { module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], + imports: [ + MongoMemoryDatabaseModule.forRoot(), + LoggerModule, + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, ignoreEnvVars: true }), + ], providers: [ { provide: AccountService, @@ -649,12 +655,22 @@ describe('[ImportUserModule]', () => { const result = uc.startSchoolInUserMigration(currentUser.id); await expect(result).rejects.toThrowError(MigrationAlreadyActivatedException); }); + it('should throw migrationAlreadyActivatedException with correct properties', () => { + const logMessage = new MigrationAlreadyActivatedException().getLogMessage(); + expect(logMessage).toBeDefined(); + expect(logMessage).toHaveProperty('message', 'Migration is already activated for this school'); + }); it('should throw if school has no officialSchoolNumber ', async () => { school.officialSchoolNumber = undefined; schoolServiceSpy = schoolService.getSchoolById.mockResolvedValueOnce(createMockSchoolDo(school)); const result = uc.startSchoolInUserMigration(currentUser.id); await expect(result).rejects.toThrowError(MissingSchoolNumberException); }); + it('should throw missingSchoolNumberException with correct properties', () => { + const logMessage = new MissingSchoolNumberException().getLogMessage(); + expect(logMessage).toBeDefined(); + expect(logMessage).toHaveProperty('message', 'The school is missing a official school number'); + }); it('should throw if school already has a persisted LDAP ', async () => { dateSpy.mockRestore(); school = schoolFactory.buildWithId({ systems: [system] }); @@ -662,6 +678,11 @@ describe('[ImportUserModule]', () => { const result = uc.startSchoolInUserMigration(currentUser.id, false); await expect(result).rejects.toThrowError(LdapAlreadyPersistedException); }); + it('should throw ldapAlreadyPersistedException with correct properties', () => { + const logMessage = new LdapAlreadyPersistedException().getLogMessage(); + expect(logMessage).toBeDefined(); + expect(logMessage).toHaveProperty('message', 'LDAP is already Persisted'); + }); it('should not throw if school has no school number but its own LDAP', async () => { school.officialSchoolNumber = undefined; schoolServiceSpy = schoolService.getSchoolById.mockResolvedValueOnce(createMockSchoolDo(school)); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index 9e14e1320c3..25c002601ef 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -6,8 +6,8 @@ import { EntityId, IFindOptions, IImportUserScope, - ImportUser, INameMatch, + ImportUser, MatchCreator, MatchCreatorScope, Permission, @@ -19,10 +19,19 @@ import { import { Configuration } from '@hpi-schul-cloud/commons'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; import { AuthorizationService } from '@src/modules/authorization'; -import { SchoolService } from '../../school'; +import { SchoolService } from '@src/modules/school'; +import { + MigrationMayBeCompleted, + MigrationMayNotBeCompleted, + SchoolIdDoesNotMatchWithUserSchoolId, + SchoolInUserMigrationEndLoggable, + SchoolInUserMigrationStartLoggable, + UserMigrationIsNotEnabled, +} from '../loggable'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -42,13 +51,17 @@ export class UserImportUc { private readonly authorizationService: AuthorizationService, private readonly schoolService: SchoolService, private readonly systemRepo: SystemRepo, - private readonly userRepo: UserRepo - ) {} + private readonly userRepo: UserRepo, + private readonly logger: Logger + ) { + this.logger.setContext(UserImportUc.name); + } private checkFeatureEnabled(school: SchoolDO): void | never { const enabled = Configuration.get('FEATURE_USER_MIGRATION_ENABLED') as boolean; const isLdapPilotSchool = school.features && school.features.includes(SchoolFeatures.LDAP_UNIVENTION_MIGRATION); if (!enabled && !isLdapPilotSchool) { + this.logger.warning(new UserMigrationIsNotEnabled()); throw new InternalServerErrorException('User Migration not enabled'); } } @@ -89,6 +102,9 @@ export class UserImportUc { // check same school if (!school.id || school.id !== userMatch.school.id || school.id !== importUser.school.id) { + this.logger.warning( + new SchoolIdDoesNotMatchWithUserSchoolId(userMatch.school.id, importUser.school.id, school.id) + ); throw new ForbiddenException('not same school'); } @@ -109,6 +125,7 @@ export class UserImportUc { const importUser = await this.importUserRepo.findById(importUserId); // check same school if (school.id !== importUser.school.id) { + this.logger.warning(new SchoolIdDoesNotMatchWithUserSchoolId('', importUser.school.id, school.id)); throw new ForbiddenException('not same school'); } @@ -126,6 +143,7 @@ export class UserImportUc { // check same school if (school.id !== importUser.school.id) { + this.logger.warning(new SchoolIdDoesNotMatchWithUserSchoolId('', importUser.school.id, school.id)); throw new ForbiddenException('not same school'); } @@ -166,15 +184,33 @@ export class UserImportUc { const options: IFindOptions = {}; // TODO Change ImportUserRepo to DO to fix this workaround const [importUsers, total] = await this.importUserRepo.findImportUsers(currentUser.school, filters, options); + let migratedUser = 0; if (total > 0) { + this.logger.notice({ + getLogMessage: () => { + return { + message: 'start saving all matched users', + numberOfMatchedUser: total, + }; + }, + }); for (const importUser of importUsers) { // TODO: Find a better solution for this loop // this needs to be synchronous, because otherwise it was leading to // server crush when working with larger number of users (e.g. 1000) // eslint-disable-next-line no-await-in-loop await this.updateUserAndAccount(importUser, school); + migratedUser += 1; } } + this.logger.notice({ + getLogMessage: () => { + return { + message: 'number of already migrated users from the total number', + numberOfMigratedUser: `${migratedUser}/${total}`, + }; + }, + }); // TODO Change ImportUserRepo to DO to fix this workaround // Delete all remaining importUser-objects that dont need to be ported await this.importUserRepo.deleteImportUsersBySchool(currentUser.school); @@ -186,6 +222,7 @@ export class UserImportUc { const school: SchoolDO = await this.schoolService.getSchoolById(currentUser.school.id); this.checkFeatureEnabled(school); if (!school.externalId || school.inUserMigration !== true || !school.inMaintenanceSince) { + this.logger.warning(new MigrationMayBeCompleted(school.inUserMigration)); throw new BadRequestException('School cannot exit from user migration mode'); } school.inUserMigration = false; @@ -195,7 +232,7 @@ export class UserImportUc { async startSchoolInUserMigration(currentUserId: EntityId, useCentralLdap = true): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); const school: SchoolDO = await this.schoolService.getSchoolById(currentUser.school.id); - + this.logger.notice(new SchoolInUserMigrationStartLoggable(currentUserId, school.name, useCentralLdap)); this.checkFeatureEnabled(school); this.checkSchoolNumber(school, useCentralLdap); this.checkSchoolNotInMigration(school); @@ -219,10 +256,12 @@ export class UserImportUc { const school: SchoolDO = await this.schoolService.getSchoolById(currentUser.school.id); this.checkFeatureEnabled(school); if (school.inUserMigration !== false || !school.inMaintenanceSince || !school.externalId) { + this.logger.warning(new MigrationMayNotBeCompleted(school.inUserMigration)); throw new BadRequestException('Sync cannot be activated for school'); } school.inMaintenanceSince = undefined; await this.schoolService.save(school); + this.logger.notice(new SchoolInUserMigrationEndLoggable(school.name)); } private async getCurrentUser(currentUserId: EntityId, permission: UserImportPermissions): Promise { diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index 70d6516857b..9193d58d4fa 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -2,7 +2,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Permission, School, System } from '@shared/domain'; +import { Permission, School, System, User } from '@shared/domain'; import { UserLoginMigration } from '@shared/domain/entity/user-login-migration.entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { @@ -11,16 +11,17 @@ import { systemFactory, TestApiClient, UserAndAccountTestFactory, + userFactory, + userLoginMigrationFactory, } from '@shared/testing'; import { JwtTestFactory } from '@shared/testing/factory/jwt.test.factory'; -import { userLoginMigrationFactory } from '@shared/testing/factory/user-login-migration.factory'; import { OauthTokenResponse } from '@src/modules/oauth/service/dto'; -import { SanisResponse, SanisRole } from '@src/modules/provisioning'; import { ServerTestModule } from '@src/modules/server'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { UUID } from 'bson'; import { Response } from 'supertest'; +import { SanisResponse, SanisRole } from '@src/modules/provisioning/strategy/sanis/response'; import { UserLoginMigrationResponse } from '../dto'; import { Oauth2MigrationParams } from '../dto/oauth2-migration.params'; @@ -128,6 +129,93 @@ describe('UserLoginMigrationController (API)', () => { }); }); + describe('[GET] /user-login-migrations/schools/:schoolId', () => { + describe('when a user login migration is found', () => { + const setup = async () => { + const date: Date = new Date(2023, 5, 4); + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + }); + const userLoginMigration: UserLoginMigration = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: date, + mandatorySince: date, + closedAt: undefined, + finishedAt: undefined, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser, userLoginMigration]); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + sourceSystem, + targetSystem, + loggedInClient, + userLoginMigration, + school, + }; + }; + + it('should return the users migration', async () => { + const { sourceSystem, targetSystem, userLoginMigration, loggedInClient, school } = await setup(); + + const response: Response = await loggedInClient.get(`schools/${school.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + sourceSystemId: sourceSystem.id, + targetSystemId: targetSystem.id, + startedAt: userLoginMigration.startedAt.toISOString(), + closedAt: userLoginMigration.closedAt?.toISOString(), + finishedAt: userLoginMigration.finishedAt?.toISOString(), + mandatorySince: userLoginMigration.mandatorySince?.toISOString(), + }); + }); + }); + + describe('when no user login migration is found', () => { + const setup = async () => { + const school: School = schoolFactory.buildWithId(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([school, adminAccount, adminUser]); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + school, + }; + }; + + it('should return the users migration', async () => { + const { loggedInClient, school } = await setup(); + + const response: Response = await loggedInClient.get(`schools/${school.id}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when unauthorized', () => { + it('should return Unauthorized', async () => { + const response: Response = await testApiClient.get(`schools/${new ObjectId().toHexString()}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); + describe('[POST] /start', () => { describe('when current User start the migration successfully', () => { const setup = async () => { @@ -335,16 +423,15 @@ describe('UserLoginMigrationController (API)', () => { }, personenkontexte: [ { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713'), + id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), rolle: SanisRole.LEHR, organisation: { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713'), + id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), kennung: officialSchoolNumber, name: 'schulName', typ: 'not necessary', }, personenstatus: 'not necessary', - email: 'email', }, ], }); @@ -882,4 +969,238 @@ describe('UserLoginMigrationController (API)', () => { }); }); }); + + describe('[POST] /close', () => { + describe('when the user login migration is running', () => { + const setup = async () => { + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + const userLoginMigration: UserLoginMigration = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + }); + + const migratedUser: User = userFactory.buildWithId({ + lastLoginSystemChange: new Date(2023, 1, 5), + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + adminAccount, + adminUser, + userLoginMigration, + migratedUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + }; + }; + + it('should return ok', async () => { + const { loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.status).toEqual(HttpStatus.CREATED); + }); + + it('should return the closed user login migration', async () => { + const { loggedInClient, userLoginMigration } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.body).toEqual({ + targetSystemId: userLoginMigration.targetSystem.id, + sourceSystemId: userLoginMigration.sourceSystem?.id, + startedAt: userLoginMigration.startedAt.toISOString(), + closedAt: expect.any(String), + finishedAt: expect.any(String), + }); + }); + }); + + describe('when migration is not started', () => { + const setup = async () => { + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + }; + }; + + it('should return a not found', async () => { + const { loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when the migration is already closed', () => { + const setup = async () => { + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + + const userLoginMigration: UserLoginMigration = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + closedAt: new Date(2023, 1, 5), + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser, userLoginMigration]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + }; + }; + + it('should return the same user login migration', async () => { + const { loggedInClient, userLoginMigration } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.body).toEqual({ + targetSystemId: userLoginMigration.targetSystem.id, + sourceSystemId: userLoginMigration.sourceSystem?.id, + startedAt: userLoginMigration.startedAt.toISOString(), + closedAt: userLoginMigration.closedAt?.toISOString(), + }); + }); + }); + + describe('when the migration is finished', () => { + const setup = async () => { + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + + const userLoginMigration: UserLoginMigration = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + closedAt: new Date(2023, 1, 5), + finishedAt: new Date(2023, 1, 6), + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser, userLoginMigration]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + }; + }; + + it('should return unprocessable entity', async () => { + const { loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.status).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + + describe('when user is not authorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.post('/close'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const sourceSystem: System = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: System = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: School = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + + const userLoginMigration: UserLoginMigration = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + closedAt: new Date(2023, 1, 5), + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, []); + + await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser, userLoginMigration]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + }; + }; + + it('should return forbidden', async () => { + const { loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/school-id.params.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/school-id.params.ts new file mode 100644 index 00000000000..ad2b769b4fb --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/school-id.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain'; +import { IsMongoId } from 'class-validator'; + +export class SchoolIdParams { + @ApiProperty() + @IsMongoId() + schoolId!: EntityId; +} diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts index a548c6b833e..7ef6242eb72 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Get, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiForbiddenResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, ApiOkResponse, + ApiOperation, ApiTags, ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, @@ -19,6 +20,7 @@ import { } from '../error'; import { UserLoginMigrationMapper } from '../mapper'; import { + CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, StartUserLoginMigrationUc, ToggleUserLoginMigrationUc, @@ -31,6 +33,7 @@ import { UserLoginMigrationSearchParams, } from './dto'; import { Oauth2MigrationParams } from './dto/oauth2-migration.params'; +import { SchoolIdParams } from './dto/request/school-id.params'; import { UserLoginMigrationMandatoryParams } from './dto/request/user-login-migration-mandatory.params'; @ApiTags('UserLoginMigration') @@ -41,13 +44,18 @@ export class UserLoginMigrationController { private readonly userLoginMigrationUc: UserLoginMigrationUc, private readonly startUserLoginMigrationUc: StartUserLoginMigrationUc, private readonly restartUserLoginMigrationUc: RestartUserLoginMigrationUc, - private readonly toggleUserLoginMigrationUc: ToggleUserLoginMigrationUc + private readonly toggleUserLoginMigrationUc: ToggleUserLoginMigrationUc, + private readonly closeUserLoginMigrationUc: CloseUserLoginMigrationUc ) {} @Get() @ApiForbiddenResponse() + @ApiOperation({ + summary: 'Get UserLoginMigrations', + description: 'Currently there can only be one migration for a user. Therefore only one migration is returned.', + }) @ApiOkResponse({ description: 'UserLoginMigrations has been found.', type: UserLoginMigrationSearchListResponse }) - @ApiInternalServerErrorResponse({ description: 'Cannot find Sanis system information.' }) + @ApiInternalServerErrorResponse({ description: 'Cannot find target system information.' }) async getMigrations( @CurrentUser() user: ICurrentUser, @Query() params: UserLoginMigrationSearchParams @@ -74,6 +82,25 @@ export class UserLoginMigrationController { return response; } + @Get('schools/:schoolId') + @ApiForbiddenResponse() + @ApiOkResponse({ description: 'UserLoginMigrations has been found', type: UserLoginMigrationResponse }) + @ApiNotFoundResponse({ description: 'Cannot find UserLoginMigration' }) + async findUserLoginMigrationBySchool( + @CurrentUser() user: ICurrentUser, + @Param() params: SchoolIdParams + ): Promise { + const userLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationUc.findUserLoginMigrationBySchool( + user.userId, + params.schoolId + ); + + const response: UserLoginMigrationResponse = + UserLoginMigrationMapper.mapUserLoginMigrationDoToResponse(userLoginMigration); + + return response; + } + @Post('start') @ApiUnprocessableEntityResponse({ description: 'User login migration is already closed and cannot be modified', @@ -153,6 +180,34 @@ export class UserLoginMigrationController { return migrationResponse; } + @Post('close') + @ApiUnprocessableEntityResponse({ + description: 'User login migration is already closed and cannot be modified. Restart is possible.', + type: UserLoginMigrationAlreadyClosedLoggableException, + }) + @ApiUnprocessableEntityResponse({ + description: 'User login migration is already closed and cannot be modified. It cannot be restarted.', + type: UserLoginMigrationGracePeriodExpiredLoggableException, + }) + @ApiNotFoundResponse({ + description: 'User login migration does not exist', + type: UserLoginMigrationNotFoundLoggableException, + }) + @ApiOkResponse({ description: 'User login migration closed', type: UserLoginMigrationResponse }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async closeMigration(@CurrentUser() currentUser: ICurrentUser): Promise { + const userLoginMigration: UserLoginMigrationDO = await this.closeUserLoginMigrationUc.closeMigration( + currentUser.userId, + currentUser.schoolId + ); + + const migrationResponse: UserLoginMigrationResponse = + UserLoginMigrationMapper.mapUserLoginMigrationDoToResponse(userLoginMigration); + + return migrationResponse; + } + @Post('migrate-to-oauth2') @ApiOkResponse({ description: 'The User has been successfully migrated.', status: 200 }) @ApiInternalServerErrorResponse({ description: 'The migration of the User was not possible.' }) diff --git a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts index e17a194f58c..2636e35c86c 100644 --- a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts @@ -5,6 +5,7 @@ import { UserLoginMigrationRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; +import { performance } from 'perf_hooks'; import { OAuthMigrationError } from '../error'; @Injectable() diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts index 7f89eb4a84f..44d402fa46f 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts @@ -160,7 +160,7 @@ describe('UserLoginMigrationService', () => { }); }); - describe('setMigration is called', () => { + describe('setMigration', () => { describe('when first starting the migration', () => { describe('when the school has no systems', () => { const setup = () => { @@ -531,6 +531,17 @@ describe('UserLoginMigrationService', () => { }; }; + it('should call schoolService.removeFeature', async () => { + const { schoolId } = setup(); + + await service.setMigration(schoolId, undefined, undefined, true); + + expect(schoolService.removeFeature).toHaveBeenCalledWith( + schoolId, + SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION + ); + }); + it('should save the UserLoginMigration with close date and finish date', async () => { const { schoolId, userLoginMigration } = setup(); const expected: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ @@ -547,7 +558,7 @@ describe('UserLoginMigrationService', () => { }); }); - describe('startMigration is called', () => { + describe('startMigration', () => { describe('when schoolId is given', () => { const setup = () => { const schoolId: EntityId = new ObjectId().toHexString(); @@ -739,7 +750,7 @@ describe('UserLoginMigrationService', () => { }); }); - describe('findMigrationBySchool is called', () => { + describe('findMigrationBySchool', () => { describe('when a UserLoginMigration exists for the school', () => { const setup = () => { const schoolId = new ObjectId().toHexString(); @@ -792,76 +803,6 @@ describe('UserLoginMigrationService', () => { }); }); - describe('restartMigration is called', () => { - describe('when migration restart was successfully', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigrationDO); - schoolMigrationService.unmarkOutdatedUsers.mockResolvedValue(); - userLoginMigrationRepo.save.mockResolvedValue(userLoginMigrationDO); - - return { - schoolId, - targetSystemId, - userLoginMigrationDO, - }; - }; - - it('should call save the user login migration', async () => { - const { schoolId, userLoginMigrationDO } = setup(); - - await service.restartMigration(schoolId); - - expect(userLoginMigrationRepo.save).toHaveBeenCalledWith(userLoginMigrationDO); - }); - - it('should call unmark the outdated users from this migration', async () => { - const { schoolId } = setup(); - - await service.restartMigration(schoolId); - - expect(schoolMigrationService.unmarkOutdatedUsers).toHaveBeenCalledWith(schoolId); - }); - }); - - describe('when migration could not be found', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - targetSystemId, - userLoginMigrationDO, - }; - }; - - it('should throw ModifyUserLoginMigrationLoggableException ', async () => { - const { schoolId } = setup(); - - const func = async () => service.restartMigration(schoolId); - - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); - }); - }); - }); - describe('deleteUserLoginMigration', () => { describe('when a userLoginMigration is given', () => { const setup = () => { @@ -882,7 +823,7 @@ describe('UserLoginMigrationService', () => { }); }); - describe('restartMigration is called', () => { + describe('restartMigration', () => { describe('when migration restart was successfully', () => { const setup = () => { const schoolId: EntityId = new ObjectId().toHexString(); @@ -954,7 +895,7 @@ describe('UserLoginMigrationService', () => { }); }); - describe('setMigrationMandatory is called', () => { + describe('setMigrationMandatory', () => { describe('when migration is set to mandatory', () => { const setup = () => { const schoolId: EntityId = new ObjectId().toHexString(); @@ -1054,4 +995,73 @@ describe('UserLoginMigrationService', () => { }); }); }); + + describe('closeMigration', () => { + describe('when a migration can be closed', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + const closedUserLoginMigration = new UserLoginMigrationDO({ + ...userLoginMigration, + closedAt: mockedDate, + finishedAt: finishDate, + }); + + userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); + userLoginMigrationRepo.save.mockResolvedValue(closedUserLoginMigration); + + return { + schoolId, + closedUserLoginMigration, + }; + }; + + it('should call schoolService.removeFeature', async () => { + const { schoolId } = setup(); + + await service.closeMigration(schoolId); + + expect(schoolService.removeFeature).toHaveBeenCalledWith( + schoolId, + SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION + ); + }); + + it('should save the closed user login migration', async () => { + const { schoolId, closedUserLoginMigration } = setup(); + + await service.closeMigration(schoolId); + + expect(userLoginMigrationRepo.save).toHaveBeenCalledWith(closedUserLoginMigration); + }); + + it('should return the closed user login migration', async () => { + const { schoolId, closedUserLoginMigration } = setup(); + + const result = await service.closeMigration(schoolId); + + expect(result).toEqual(closedUserLoginMigration); + }); + }); + + describe('when a migration can be closed', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + + userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); + + return { + schoolId, + }; + }; + + it('should save the closed user login migration', async () => { + const { schoolId } = setup(); + + const func = () => service.closeMigration(schoolId); + + await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts index 7e550ccecb5..1f48b814247 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts @@ -18,6 +18,14 @@ export class UserLoginMigrationService { private readonly schoolMigrationService: SchoolMigrationService ) {} + /** + * @deprecated Use the other functions in this class instead. + * + * @param schoolId + * @param oauthMigrationPossible + * @param oauthMigrationMandatory + * @param oauthMigrationFinished + */ async setMigration( schoolId: EntityId, oauthMigrationPossible?: boolean, @@ -63,6 +71,11 @@ export class UserLoginMigrationService { const savedMigration: UserLoginMigrationDO = await this.userLoginMigrationRepo.save(userLoginMigration); + if (oauthMigrationFinished !== undefined) { + // this would throw an error when executed before the userLoginMigrationRepo.save method. + await this.schoolService.removeFeature(schoolId, SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION); + } + return savedMigration; } @@ -113,6 +126,26 @@ export class UserLoginMigrationService { return userLoginMigration; } + async closeMigration(schoolId: string): Promise { + let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); + + if (!userLoginMigration) { + throw new UserLoginMigrationNotFoundLoggableException(schoolId); + } + + await this.schoolService.removeFeature(schoolId, SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION); + + const now: Date = new Date(); + const gracePeriodDuration: number = Configuration.get('MIGRATION_END_GRACE_PERIOD_MS') as number; + + userLoginMigration.closedAt = now; + userLoginMigration.finishedAt = new Date(now.getTime() + gracePeriodDuration); + + userLoginMigration = await this.userLoginMigrationRepo.save(userLoginMigration); + + return userLoginMigration; + } + private async createNewMigration(school: SchoolDO): Promise { const oauthSystems: SystemDto[] = await this.systemService.findByType(SystemTypeEnum.OAUTH); const sanisSystem: SystemDto | undefined = oauthSystems.find((system: SystemDto) => system.alias === 'SANIS'); diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts index 2dab1b1116e..c4729ec1bc9 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts @@ -2,15 +2,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { ObjectId } from '@mikro-orm/mongodb'; -import { - BadRequestException, - InternalServerErrorException, - NotFoundException, - UnprocessableEntityException, -} from '@nestjs/common'; +import { BadRequestException, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { RoleName, SchoolDO, UserDO, UserLoginMigrationDO } from '@shared/domain'; -import { UserLoginMigrationRepo } from '@shared/repo'; +import { RoleName, SchoolDO, UserDO } from '@shared/domain'; import { schoolDOFactory, setupEntities, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; @@ -34,7 +28,6 @@ describe('UserMigrationService', () => { let systemService: DeepMocked; let userService: DeepMocked; let accountService: DeepMocked; - let userLoginMigrationRepo: DeepMocked; const hostUri = 'http://this.de'; const apiUrl = 'http://mock.de'; @@ -69,10 +62,6 @@ describe('UserMigrationService', () => { provide: LegacyLogger, useValue: createMock(), }, - { - provide: UserLoginMigrationRepo, - useValue: createMock(), - }, ], }).compile(); @@ -82,7 +71,6 @@ describe('UserMigrationService', () => { userService = module.get(UserService); accountService = module.get(AccountService); logger = module.get(LegacyLogger); - userLoginMigrationRepo = module.get(UserLoginMigrationRepo); await setupEntities(); }); @@ -103,28 +91,7 @@ describe('UserMigrationService', () => { const officialSchoolNumber = '3'; const school: SchoolDO = schoolDOFactory.buildWithId({ name: 'schoolName', officialSchoolNumber }); - const iservSystem: SystemDto = new SystemDto({ - id: 'iservId', - type: '', - alias: 'Schulserver', - }); - const sanisSystem: SystemDto = new SystemDto({ - id: 'sanisId', - type: '', - alias: 'SANIS', - }); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([iservSystem, sanisSystem]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue( - new UserLoginMigrationDO({ - id: 'userLoginMigrationId', - schoolId: school.id as string, - targetSystemId: 'targetSystemId', - startedAt: new Date(), - mandatorySince: new Date(), - }) - ); return { officialSchoolNumber, @@ -136,9 +103,7 @@ describe('UserMigrationService', () => { const result: string = await service.getMigrationConsentPageRedirect(officialSchoolNumber, 'iservId'); - expect(result).toEqual( - 'http://this.de/migration?sourceSystem=iservId&targetSystem=sanisId&origin=iservId&mandatory=true' - ); + expect(result).toEqual('http://this.de/migration?origin=iservId'); }); }); @@ -162,31 +127,6 @@ describe('UserMigrationService', () => { await expect(promise).rejects.toThrow(NotFoundException); }); }); - - describe('when the migration systems have invalid data', () => { - const setup = () => { - const officialSchoolNumber = '3'; - - schoolService.getSchoolBySchoolNumber.mockResolvedValue(schoolDOFactory.buildWithId({ officialSchoolNumber })); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - systemService.findByType.mockResolvedValue([]); - - return { - officialSchoolNumber, - }; - }; - - it('should throw InternalServerErrorException', async () => { - const { officialSchoolNumber } = setup(); - - const promise: Promise = service.getMigrationConsentPageRedirect( - officialSchoolNumber, - 'unknownSystemId' - ); - - await expect(promise).rejects.toThrow(InternalServerErrorException); - }); - }); }); describe('getMigrationRedirectUri is called', () => { diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts index eeae6fa9572..2de31471a85 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts @@ -1,22 +1,14 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { - BadRequestException, - Injectable, - InternalServerErrorException, - NotFoundException, - UnprocessableEntityException, -} from '@nestjs/common'; -import { UserLoginMigrationDO } from '@shared/domain'; +import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { SchoolDO } from '@shared/domain/domainobject/school.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { UserLoginMigrationRepo } from '@shared/repo/userloginmigration/user-login-migration.repo'; import { LegacyLogger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; import { AccountDto } from '@src/modules/account/services/dto'; import { SchoolService } from '@src/modules/school'; import { SystemDto, SystemService } from '@src/modules/system/service'; import { UserService } from '@src/modules/user'; -import { EntityId, SystemTypeEnum } from '@src/shared/domain/types'; +import { EntityId } from '@src/shared/domain/types'; import { PageTypes } from '../interface/page-types.enum'; import { MigrationDto } from './dto/migration.dto'; import { PageContentDto } from './dto/page-content.dto'; @@ -41,8 +33,7 @@ export class UserMigrationService { private readonly systemService: SystemService, private readonly userService: UserService, private readonly logger: LegacyLogger, - private readonly accountService: AccountService, - private readonly userLoginMigrationRepo: UserLoginMigrationRepo + private readonly accountService: AccountService ) { this.hostUrl = Configuration.get('HOST') as string; this.publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; @@ -55,27 +46,8 @@ export class UserMigrationService { throw new NotFoundException(`School with offical school number ${officialSchoolNumber} does not exist.`); } - const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(school.id); - - const oauthSystems: SystemDto[] = await this.systemService.findByType(SystemTypeEnum.OAUTH); - const sanisSystem: SystemDto | undefined = oauthSystems.find( - (system: SystemDto): boolean => system.alias === 'SANIS' - ); - const iservSystem: SystemDto | undefined = oauthSystems.find( - (system: SystemDto): boolean => system.alias === 'Schulserver' - ); - - if (!iservSystem?.id || !sanisSystem?.id) { - throw new InternalServerErrorException( - 'Unable to generate migration redirect url. Iserv or Sanis system information is invalid.' - ); - } - const url = new URL('/migration', this.hostUrl); - url.searchParams.append('sourceSystem', iservSystem.id); - url.searchParams.append('targetSystem', sanisSystem.id); url.searchParams.append('origin', originSystemId); - url.searchParams.append('mandatory', (!!userLoginMigration?.mandatorySince).toString()); return url.toString(); } diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts new file mode 100644 index 00000000000..1ea3fa50954 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts @@ -0,0 +1,220 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, UserLoginMigrationDO } from '@shared/domain'; +import { setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { UserLoginMigrationNotFoundLoggableException } from '../error'; +import { SchoolMigrationService, UserLoginMigrationRevertService, UserLoginMigrationService } from '../service'; +import { CloseUserLoginMigrationUc } from './close-user-login-migration.uc'; + +describe('CloseUserLoginMigrationUc', () => { + let module: TestingModule; + let uc: CloseUserLoginMigrationUc; + + let userLoginMigrationService: DeepMocked; + let schoolMigrationService: DeepMocked; + let userLoginMigrationRevertService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + CloseUserLoginMigrationUc, + { + provide: UserLoginMigrationService, + useValue: createMock(), + }, + { + provide: SchoolMigrationService, + useValue: createMock(), + }, + { + provide: UserLoginMigrationRevertService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(CloseUserLoginMigrationUc); + userLoginMigrationService = module.get(UserLoginMigrationService); + schoolMigrationService = module.get(SchoolMigrationService); + userLoginMigrationRevertService = module.get(UserLoginMigrationRevertService); + authorizationService = module.get(AuthorizationService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('closeMigration', () => { + describe('when the user login migration was closed after a migration', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + const closedUserLoginMigration = new UserLoginMigrationDO({ + ...userLoginMigration, + closedAt: new Date(2023, 1), + }); + const schoolId = 'schoolId'; + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.closeMigration.mockResolvedValue(closedUserLoginMigration); + schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(true); + + return { + user, + schoolId, + userLoginMigration, + closedUserLoginMigration, + }; + }; + + it('should check the permission', async () => { + const { user, schoolId, userLoginMigration } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, userLoginMigration, { + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + action: Action.write, + }); + }); + + it('should close the migration', async () => { + const { user, schoolId } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(userLoginMigrationService.closeMigration).toHaveBeenCalledWith(schoolId); + }); + + it('should mark all un-migrated users as outdated', async () => { + const { user, schoolId } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(schoolMigrationService.markUnmigratedUsersAsOutdated).toHaveBeenCalledWith(schoolId); + }); + + it('should return the closed user login migration', async () => { + const { user, schoolId, closedUserLoginMigration } = setup(); + + const result = await uc.closeMigration(user.id, schoolId); + + expect(result).toEqual(closedUserLoginMigration); + }); + }); + + describe('when no user login migration exists', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const schoolId = 'schoolId'; + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); + + return { + user, + schoolId, + }; + }; + + it('should throw a not found exception', async () => { + const { user, schoolId } = setup(); + + const func = () => uc.closeMigration(user.id, schoolId); + + await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + }); + }); + + describe('when the user login migration was closed without any migration', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + const closedUserLoginMigration = new UserLoginMigrationDO({ + ...userLoginMigration, + closedAt: new Date(2023, 1), + }); + const schoolId = 'schoolId'; + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.closeMigration.mockResolvedValue(closedUserLoginMigration); + schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); + + return { + user, + schoolId, + userLoginMigration, + closedUserLoginMigration, + }; + }; + + it('should check the permission', async () => { + const { user, schoolId, userLoginMigration } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, userLoginMigration, { + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + action: Action.write, + }); + }); + + it('should revert the start of the migration', async () => { + const { user, schoolId, closedUserLoginMigration } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(userLoginMigrationRevertService.revertUserLoginMigration).toHaveBeenCalledWith(closedUserLoginMigration); + }); + }); + + describe('when the user login migration was already closed', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + closedAt: new Date(2023, 1), + }); + const schoolId = 'schoolId'; + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); + + return { + user, + schoolId, + userLoginMigration, + }; + }; + + it('should not modify the user login migration', async () => { + const { user, schoolId } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(userLoginMigrationService.closeMigration).not.toHaveBeenCalled(); + }); + + it('should return the already closed user login migration', async () => { + const { user, schoolId, userLoginMigration } = setup(); + + const result = await uc.closeMigration(user.id, schoolId); + + expect(result).toEqual(userLoginMigration); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts new file mode 100644 index 00000000000..ad6646edae8 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; +import { EntityId, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { + UserLoginMigrationGracePeriodExpiredLoggableException, + UserLoginMigrationNotFoundLoggableException, +} from '../error'; +import { SchoolMigrationService, UserLoginMigrationRevertService, UserLoginMigrationService } from '../service'; + +@Injectable() +export class CloseUserLoginMigrationUc { + constructor( + private readonly userLoginMigrationService: UserLoginMigrationService, + private readonly schoolMigrationService: SchoolMigrationService, + private readonly userLoginMigrationRevertService: UserLoginMigrationRevertService, + private readonly authorizationService: AuthorizationService + ) {} + + async closeMigration(userId: EntityId, schoolId: EntityId): Promise { + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + schoolId + ); + + if (!userLoginMigration) { + throw new UserLoginMigrationNotFoundLoggableException(schoolId); + } + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, userLoginMigration, { + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + action: Action.write, + }); + + if (userLoginMigration.finishedAt && this.isGracePeriodExpired(userLoginMigration)) { + throw new UserLoginMigrationGracePeriodExpiredLoggableException( + userLoginMigration.id as string, + userLoginMigration.finishedAt + ); + } else if (userLoginMigration.closedAt) { + return userLoginMigration; + } else { + const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.closeMigration( + schoolId + ); + + const hasSchoolMigratedUser: boolean = await this.schoolMigrationService.hasSchoolMigratedUser(schoolId); + + if (!hasSchoolMigratedUser) { + await this.userLoginMigrationRevertService.revertUserLoginMigration(updatedUserLoginMigration); + } else { + await this.schoolMigrationService.markUnmigratedUsersAsOutdated(schoolId); + } + + return updatedUserLoginMigration; + } + } + + private isGracePeriodExpired(userLoginMigration: UserLoginMigrationDO): boolean { + const isGracePeriodExpired: boolean = + !!userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime(); + + return isGracePeriodExpired; + } +} diff --git a/apps/server/src/modules/user-login-migration/uc/index.ts b/apps/server/src/modules/user-login-migration/uc/index.ts index 38e3f5197bc..18163b59a9f 100644 --- a/apps/server/src/modules/user-login-migration/uc/index.ts +++ b/apps/server/src/modules/user-login-migration/uc/index.ts @@ -3,3 +3,4 @@ export * from './user-login-migration.uc'; export * from './start-user-login-migration.uc'; export * from './toggle-user-login-migration.uc'; export * from './restart-user-login-migration.uc'; +export * from './close-user-login-migration.uc'; diff --git a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts index 2b837019144..a515755830a 100644 --- a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts @@ -38,7 +38,7 @@ export class RestartUserLoginMigrationUc { } else if (userLoginMigration.closedAt) { userLoginMigration = await this.userLoginMigrationService.restartMigration(schoolId); - this.logger.log(new UserLoginMigrationStartLoggable(userId, schoolId)); + this.logger.info(new UserLoginMigrationStartLoggable(userId, schoolId)); } else { // Do nothing, if migration is already started but not stopped. } diff --git a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts index d39e24cbb86..d97fe1d0ff8 100644 --- a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts @@ -28,7 +28,7 @@ export class StartUserLoginMigrationUc { if (!userLoginMigration) { userLoginMigration = await this.userLoginMigrationService.startMigration(schoolId); - this.logger.log(new UserLoginMigrationStartLoggable(userId, userLoginMigration.id as string)); + this.logger.info(new UserLoginMigrationStartLoggable(userId, userLoginMigration.id as string)); } else if (userLoginMigration.closedAt) { throw new UserLoginMigrationAlreadyClosedLoggableException( userLoginMigration.id as string, diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts index 0fca64250d7..2ca93a14c1f 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -1,17 +1,24 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Page, SchoolDO, System, UserLoginMigrationDO } from '@shared/domain'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Page, Permission, SchoolDO, System, User, UserLoginMigrationDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { schoolDOFactory, systemFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { + schoolDOFactory, + setupEntities, + systemFactory, + userFactory, + userLoginMigrationDOFactory, +} from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AuthenticationService } from '@src/modules/authentication/services/authentication.service'; +import { Action, AuthorizationService } from '@src/modules/authorization'; import { OAuthTokenDto } from '@src/modules/oauth'; import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { ProvisioningService } from '@src/modules/provisioning'; import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@src/modules/provisioning/dto'; import { SchoolService } from '@src/modules/school'; -import { AuthorizationService } from '@src/modules/authorization'; import { Oauth2MigrationParams } from '../controller/dto/oauth2-migration.params'; import { OAuthMigrationError, SchoolMigrationError, UserLoginMigrationError } from '../error'; import { PageTypes } from '../interface/page-types.enum'; @@ -29,9 +36,12 @@ describe('UserLoginMigrationUc', () => { let schoolMigrationService: DeepMocked; let userMigrationService: DeepMocked; let authenticationService: DeepMocked; + let authorizationService: DeepMocked; let logger: DeepMocked; beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ providers: [ UserLoginMigrationUc, @@ -82,6 +92,7 @@ describe('UserLoginMigrationUc', () => { schoolMigrationService = module.get(SchoolMigrationService); userMigrationService = module.get(UserMigrationService); authenticationService = module.get(AuthenticationService); + authorizationService = module.get(AuthorizationService); logger = module.get(LegacyLogger); }); @@ -189,6 +200,98 @@ describe('UserLoginMigrationUc', () => { }); }); + describe('findUserLoginMigrationBySchool', () => { + describe('when searching for an existing user login migration', () => { + const setup = () => { + const schoolId = 'schoolId'; + + const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + schoolId, + targetSystemId: 'targetSystemId', + startedAt: new Date(), + }); + const user: User = userFactory.buildWithId(); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(migration); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { user, schoolId, migration }; + }; + + it('should should check the users permission', async () => { + const { user, migration, schoolId } = setup(); + + await uc.findUserLoginMigrationBySchool(user.id, schoolId); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, migration, { + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + action: Action.read, + }); + }); + + it('should return the user login migration', async () => { + const { user, migration, schoolId } = setup(); + + const result: UserLoginMigrationDO = await uc.findUserLoginMigrationBySchool(user.id, schoolId); + + expect(result).toEqual(migration); + }); + }); + + describe('when a user login migration does not exist', () => { + const setup = () => { + const schoolId = 'schoolId'; + + const user: User = userFactory.buildWithId(); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { user, schoolId }; + }; + + it('should return throw not found exception', async () => { + const { user, schoolId } = setup(); + + const func = () => uc.findUserLoginMigrationBySchool(user.id, schoolId); + + await expect(func).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the authorization fails', () => { + const setup = () => { + const schoolId = 'schoolId'; + + const user: User = userFactory.buildWithId(); + + const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + schoolId, + targetSystemId: 'targetSystemId', + startedAt: new Date(), + }); + + const error = new Error('Authorization failed'); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValue(migration); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { user, schoolId, error }; + }; + + it('should throw an error', async () => { + const { user, schoolId, error } = setup(); + + const func = () => uc.findUserLoginMigrationBySchool(user.id, schoolId); + + await expect(func).rejects.toThrow(error); + }); + }); + }); + describe('migrate', () => { describe('when user migrates the from one to another system', () => { const setupMigration = () => { diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts index 87915c2c7eb..121e236cea8 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts @@ -1,7 +1,9 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; -import { EntityId, Page, SchoolDO, UserLoginMigrationDO } from '@shared/domain'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, Page, Permission, SchoolDO, User, UserLoginMigrationDO } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import { AuthenticationService } from '@src/modules/authentication/services/authentication.service'; +import { Action, AuthorizationService } from '@src/modules/authorization'; import { OAuthTokenDto } from '@src/modules/oauth'; import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { ProvisioningService } from '@src/modules/provisioning'; @@ -10,7 +12,7 @@ import { OAuthMigrationError, SchoolMigrationError, UserLoginMigrationError } fr import { PageTypes } from '../interface/page-types.enum'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; import { MigrationDto, PageContentDto } from '../service/dto'; -import { UserLoginMigrationQuery } from './dto/user-login-migration-query'; +import { UserLoginMigrationQuery } from './dto'; @Injectable() export class UserLoginMigrationUc { @@ -21,6 +23,7 @@ export class UserLoginMigrationUc { private readonly provisioningService: ProvisioningService, private readonly schoolMigrationService: SchoolMigrationService, private readonly authenticationService: AuthenticationService, + private readonly authorizationService: AuthorizationService, private readonly logger: LegacyLogger ) {} @@ -54,6 +57,24 @@ export class UserLoginMigrationUc { return page; } + async findUserLoginMigrationBySchool(userId: EntityId, schoolId: EntityId): Promise { + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + schoolId + ); + + if (!userLoginMigration) { + throw new NotFoundLoggableException('UserLoginMigration', 'schoolId', schoolId); + } + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, userLoginMigration, { + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + action: Action.read, + }); + + return userLoginMigration; + } + async migrate( userJwt: string, currentUserId: string, diff --git a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts index 8bed90bd475..5b85a235aab 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts @@ -9,6 +9,7 @@ import { UserLoginMigrationController } from './controller/user-login-migration. import { UserMigrationController } from './controller/user-migration.controller'; import { PageContentMapper } from './mapper'; import { + CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, StartUserLoginMigrationUc, ToggleUserLoginMigrationUc, @@ -31,6 +32,7 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; StartUserLoginMigrationUc, RestartUserLoginMigrationUc, ToggleUserLoginMigrationUc, + CloseUserLoginMigrationUc, PageContentMapper, ], controllers: [UserMigrationController, UserLoginMigrationController], diff --git a/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts b/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts index 44ea4364576..1731d10ff8e 100644 --- a/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts +++ b/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts @@ -3,14 +3,16 @@ import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ConverterUtil } from '@shared/common'; +import { axiosResponseFactory } from '@shared/testing'; +import { ErrorUtils } from '@src/core/error/utils'; import { AxiosResponse } from 'axios'; import crypto, { Hash } from 'crypto'; import { of } from 'rxjs'; import { URLSearchParams } from 'url'; -import { BBBBaseMeetingConfig, BBBCreateConfig, BBBJoinConfig, BBBRole, GuestPolicy } from './request'; +import { BbbSettings, IBbbSettings } from './bbb-settings.interface'; import { BBBService } from './bbb.service'; +import { BBBBaseMeetingConfig, BBBCreateConfig, BBBJoinConfig, BBBRole, GuestPolicy } from './request'; import { BBBBaseResponse, BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBStatus } from './response'; -import { BbbSettings, IBbbSettings } from './bbb-settings.interface'; const createBBBCreateResponse = (): BBBResponse => { return { @@ -77,15 +79,10 @@ const createBBBJoinConfig = (): BBBJoinConfig => { }; type BBBResponseType = BBBCreateResponse | BBBMeetingInfoResponse | BBBBaseResponse; -const createAxiosResponse = (data: BBBResponse): AxiosResponse> => { - return { - data: data ?? {}, - status: 0, - statusText: '', - headers: {}, - config: {}, - }; -}; +const createAxiosResponse = (data: BBBResponse) => + axiosResponseFactory.build({ + data, + }); class BBBServiceTest extends BBBService { public superToParams(object: BBBCreateConfig | BBBBaseMeetingConfig): URLSearchParams { @@ -147,23 +144,59 @@ describe('BBB Service', () => { }); describe('create', () => { - let bbbCreateResponse: AxiosResponse>; - beforeEach(() => { - bbbCreateResponse = createAxiosResponse(createBBBCreateResponse()); + describe('when valid parameter passed and the BBB response well', () => { + const setup = () => { + const bbbCreateResponse: AxiosResponse> = createAxiosResponse( + createBBBCreateResponse() + ); + + const param = createBBBCreateConfig(); + + httpService.post.mockReturnValue(of(bbbCreateResponse)); + converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); + + return { param, bbbCreateResponse }; + }; + + it('should return a response with returncode success', async () => { + const { bbbCreateResponse, param } = setup(); + + const result = await service.create(param); + + expect(result).toBeDefined(); + expect(httpService.post).toHaveBeenCalledTimes(1); + expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbCreateResponse.data); + }); }); - it('should return a response with returncode success', async () => { - // Arrange - httpService.post.mockReturnValue(of(bbbCreateResponse)); - converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); + describe('when valid parameter passed and the BBB response with error', () => { + const setup = () => { + const bbbCreateResponse: AxiosResponse> = createAxiosResponse( + createBBBCreateResponse() + ); + bbbCreateResponse.data.response.returncode = BBBStatus.ERROR; - // Act - const result = await service.create(createBBBCreateConfig()); + const param = createBBBCreateConfig(); - // Assert - expect(result).toBeDefined(); - expect(httpService.post).toHaveBeenCalledTimes(1); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbCreateResponse.data); + httpService.post.mockReturnValue(of(bbbCreateResponse)); + converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); + + const error = new InternalServerErrorException( + `${bbbCreateResponse.data.response.messageKey}, ${bbbCreateResponse.data.response.message}` + ); + const expectedError = new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'BBBService:create') + ); + + return { param, expectedError }; + }; + + it('should throw an error', async () => { + const { expectedError, param } = setup(); + + await expect(service.create(param)).rejects.toThrowError(expectedError); + }); }); it('should return a xml configuration with provided presentation url', () => { @@ -178,130 +211,169 @@ describe('BBB Service', () => { "" ); }); - - it('should throw an error if there is a different return code then success', async () => { - // Arrange - bbbCreateResponse.data.response.returncode = BBBStatus.ERROR; - httpService.get.mockReturnValue(of(bbbCreateResponse)); - converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); - const expectedError = new InternalServerErrorException( - bbbCreateResponse.data.response.messageKey, - bbbCreateResponse.data.response.message - ); - - // Act && Assert - await expect(service.create(createBBBCreateConfig())).rejects.toThrowError(expectedError); - }); }); describe('end', () => { - let bbbBaseResponse: AxiosResponse>; - let bbbBaseMeetingConfig: BBBBaseMeetingConfig; - beforeEach(() => { - bbbBaseResponse = createAxiosResponse(createBBBBaseResponse()); - bbbBaseMeetingConfig = { meetingID: 'meetingId' }; - }); + describe('when valid parameter passed and the BBB response well', () => { + const setup = () => { + const bbbBaseResponse: AxiosResponse> = createAxiosResponse( + createBBBBaseResponse() + ); + const bbbBaseMeetingConfig: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; - it('should return a response with returncode success', async () => { - // Arrange - httpService.get.mockReturnValue(of(bbbBaseResponse)); - converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); + httpService.get.mockReturnValue(of(bbbBaseResponse)); + converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); - // Act - const result = await service.end(bbbBaseMeetingConfig); + return { bbbBaseResponse, bbbBaseMeetingConfig }; + }; - // Assert - expect(result).toBeDefined(); - expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbBaseResponse.data); - }); + it('should return a response with returncode success', async () => { + const { bbbBaseResponse, bbbBaseMeetingConfig } = setup(); - it('should throw an error if there is a different return code then success', async () => { - // Arrange - bbbBaseResponse.data.response.returncode = BBBStatus.ERROR; - httpService.get.mockReturnValue(of(bbbBaseResponse)); - converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); - const expectedError = new InternalServerErrorException( - bbbBaseResponse.data.response.messageKey, - bbbBaseResponse.data.response.message - ); + const result = await service.end(bbbBaseMeetingConfig); - // Act && Assert - await expect(service.end(bbbBaseMeetingConfig)).rejects.toThrowError(expectedError); + expect(result).toBeDefined(); + expect(httpService.get).toBeCalled(); + expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbBaseResponse.data); + }); }); - }); - describe('getMeetingInfo', () => { - let bbbMeetingInfoResponse: AxiosResponse>; - let bbbBaseMeetingConfig: BBBBaseMeetingConfig; - beforeEach(() => { - bbbMeetingInfoResponse = createAxiosResponse(createBBBMeetingInfoResponse()); - bbbBaseMeetingConfig = { meetingID: 'meetingId' }; + describe('when valid parameter passed and the BBB response with error', () => { + const setup = () => { + const bbbBaseResponse: AxiosResponse> = createAxiosResponse( + createBBBBaseResponse() + ); + bbbBaseResponse.data.response.returncode = BBBStatus.ERROR; + const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; + + httpService.get.mockReturnValue(of(bbbBaseResponse)); + converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); + + const error = new InternalServerErrorException( + `${bbbBaseResponse.data.response.messageKey}, ${bbbBaseResponse.data.response.message}` + ); + const expectedError = new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'BBBService:end') + ); + + return { expectedError, param }; + }; + + it('should throw an error if there is a different return code then success', async () => { + const { param, expectedError } = setup(); + + await expect(service.end(param)).rejects.toThrowError(expectedError); + }); }); + }); - it('should return a response with returncode success', async () => { - // Arrange - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); - - // Act - const result = await service.getMeetingInfo(bbbBaseMeetingConfig); - - // Assert - expect(result).toBeDefined(); - expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); + describe('getMeetingInfo', () => { + describe('when valid parameter passed and the BBB response well', () => { + const setup = () => { + const bbbMeetingInfoResponse: AxiosResponse> = createAxiosResponse( + createBBBMeetingInfoResponse() + ); + const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; + + httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); + converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + + return { bbbMeetingInfoResponse, param }; + }; + + it('should return a response with returncode success', async () => { + const { bbbMeetingInfoResponse, param } = setup(); + const result = await service.getMeetingInfo(param); + + expect(result).toBeDefined(); + expect(httpService.get).toBeCalled(); + expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); + }); }); - it('should throw an error if there is a different return code then success', async () => { - // Arrange - bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); - const expectedError = new InternalServerErrorException( - bbbMeetingInfoResponse.data.response.messageKey, - bbbMeetingInfoResponse.data.response.message - ); - - // Act && Assert - await expect(service.getMeetingInfo(bbbBaseMeetingConfig)).rejects.toThrowError(expectedError); + describe('when valid parameter passed and the BBB response with error', () => { + const setup = () => { + const bbbMeetingInfoResponse: AxiosResponse> = createAxiosResponse( + createBBBMeetingInfoResponse() + ); + bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; + const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; + + httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); + converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + + const error = new InternalServerErrorException( + `${bbbMeetingInfoResponse.data.response.messageKey}: ${bbbMeetingInfoResponse.data.response.message}` + ); + const expectedError = new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'BBBService:getMeetingInfo') + ); + + return { expectedError, param }; + }; + + it('should throw an error if there is a different return code then success', async () => { + const { expectedError, param } = setup(); + + await expect(service.getMeetingInfo(param)).rejects.toThrowError(expectedError); + }); }); }); describe('join', () => { - let bbbMeetingInfoResponse: AxiosResponse>; - let bbbJoinConfig: BBBJoinConfig; - beforeEach(() => { - bbbMeetingInfoResponse = createAxiosResponse(createBBBMeetingInfoResponse()); - bbbJoinConfig = createBBBJoinConfig(); - }); + describe('when valid parameter passed and the BBB response well', () => { + const setup = () => { + const bbbMeetingInfoResponse: AxiosResponse> = createAxiosResponse( + createBBBMeetingInfoResponse() + ); + const param: BBBJoinConfig = createBBBJoinConfig(); - it('should create a join link to a bbb meeting', async () => { - // Arrange - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); + converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); - // Act - const url = await service.join(bbbJoinConfig); + return { param, bbbMeetingInfoResponse }; + }; - // Assert - expect(url).toBeDefined(); - expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); - }); + it('should create a join link to a bbb meeting', async () => { + const { param, bbbMeetingInfoResponse } = setup(); - it('should throw an error if there is a different return code then success', async () => { - // Arrange - bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); - const expectedError = new InternalServerErrorException( - bbbMeetingInfoResponse.data.response.messageKey, - bbbMeetingInfoResponse.data.response.message - ); + const url = await service.join(param); + + expect(url).toBeDefined(); + expect(httpService.get).toBeCalled(); + expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); + }); + }); - // Act && Assert - await expect(service.join(bbbJoinConfig)).rejects.toThrowError(expectedError); + describe('when valid parameter passed and the BBB response with error', () => { + const setup = () => { + const bbbMeetingInfoResponse: AxiosResponse> = createAxiosResponse( + createBBBMeetingInfoResponse() + ); + bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; + const param: BBBJoinConfig = createBBBJoinConfig(); + + httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); + converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + + const error = new InternalServerErrorException( + `${bbbMeetingInfoResponse.data.response.messageKey}: ${bbbMeetingInfoResponse.data.response.message}` + ); + const expectedError = new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'BBBService:getMeetingInfo') + ); + + return { param, expectedError }; + }; + + it('should throw an error if there is a different return code then success', async () => { + const { param, expectedError } = setup(); + + await expect(service.join(param)).rejects.toThrowError(expectedError); + }); }); it('toParams: should return params based on bbb configs', () => { diff --git a/apps/server/src/modules/video-conference/bbb/bbb.service.ts b/apps/server/src/modules/video-conference/bbb/bbb.service.ts index 76b5c017544..54b9ef4549b 100644 --- a/apps/server/src/modules/video-conference/bbb/bbb.service.ts +++ b/apps/server/src/modules/video-conference/bbb/bbb.service.ts @@ -1,13 +1,14 @@ -import crypto from 'crypto'; +import { HttpService } from '@nestjs/axios'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConverterUtil } from '@shared/common/utils'; +import { ErrorUtils } from '@src/core/error/utils'; import { AxiosResponse } from 'axios'; -import { HttpService } from '@nestjs/axios'; +import crypto from 'crypto'; +import { Observable, firstValueFrom } from 'rxjs'; import { URL, URLSearchParams } from 'url'; -import { firstValueFrom, Observable } from 'rxjs'; -import { ConverterUtil } from '@shared/common/utils'; import { BbbSettings, IBbbSettings } from './bbb-settings.interface'; -import { BBBBaseResponse, BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBStatus } from './response'; import { BBBBaseMeetingConfig, BBBCreateConfig, BBBJoinConfig } from './request'; +import { BBBBaseResponse, BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBStatus } from './response'; @Injectable() export class BBBService { @@ -40,21 +41,23 @@ export class BBBService { const conf = { headers: { 'Content-Type': 'application/xml' } }; const data = this.getBbbRequestConfig(this.presentationUrl); const observable: Observable> = this.httpService.post(url, data, conf); + return firstValueFrom(observable) .then((resp: AxiosResponse) => { const bbbResp = this.converterUtil.xml2object | BBBResponse>( resp.data ); if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(bbbResp.response.messageKey, bbbResp.response.message); + throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); } return bbbResp as BBBResponse; }) .catch((error) => { - throw new InternalServerErrorException(error); + throw new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(error, 'BBBService:create')); }); } + // it should be a private method getBbbRequestConfig(presentationUrl: string): string { if (presentationUrl === '') return ''; return ``; @@ -86,12 +89,12 @@ export class BBBService { .then((resp: AxiosResponse) => { const bbbResp = this.converterUtil.xml2object>(resp.data); if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(bbbResp.response.messageKey, bbbResp.response.message); + throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); } return bbbResp; }) .catch((error) => { - throw new InternalServerErrorException(error); + throw new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(error, 'BBBService:end')); }); } @@ -111,15 +114,19 @@ export class BBBService { BBBResponse | BBBResponse >(resp.data); if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(bbbResp.response.messageKey, bbbResp.response.message); + throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); } return bbbResp as BBBResponse; }) .catch((error) => { - throw new InternalServerErrorException(error); + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'BBBService:getMeetingInfo') + ); }); } + // should be private /** * Returns a SHA1 encoded checksum for the input parameters. * @param {string} callName @@ -134,6 +141,7 @@ export class BBBService { return checksum; } + // should be private /** * Extracts fields from a javascript object and builds a URLSearchParams object from it. * @param {object} object @@ -149,6 +157,7 @@ export class BBBService { return params; } + // should be private /** * Builds the url for BBB. * @param callName Name of the BBB api function. 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 d0bbf42a995..9136724d3d7 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 @@ -7,7 +7,7 @@ import { Permission, RoleName, SchoolFeatures, - TeamUser, + TeamUserEntity, UserDO, VideoConferenceDO, VideoConferenceScope, @@ -236,9 +236,9 @@ describe('VideoConferenceService', () => { const userId = user.id as EntityId; const scopeId = new ObjectId().toHexString(); - const teamUser: TeamUser = teamUserFactory.withRoleAndUserId(roleFactory.buildWithId(), userId).build(); + const teamUser: TeamUserEntity = teamUserFactory.withRoleAndUserId(roleFactory.buildWithId(), userId).build(); const team = teamFactory - .withTeamUser(teamUser) + .withTeamUser([teamUser]) .withRoleAndUserId(roleFactory.buildWithId({ name: RoleName.TEAMEXPERT }), userId) .build(); teamsRepo.findById.mockResolvedValue(team); 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 94a0491119e..eec64d1bd4a 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 @@ -6,8 +6,8 @@ import { RoleName, RoleReference, SchoolFeatures, - Team, - TeamUser, + TeamEntity, + TeamUserEntity, UserDO, VideoConferenceDO, VideoConferenceOptionsDO, @@ -67,9 +67,9 @@ export class VideoConferenceService { return isExpert; } case VideoConferenceScope.EVENT: { - const team: Team = await this.teamsRepo.findById(scopeId); - const teamUser: TeamUser | undefined = team.teamUsers.find( - (userInTeam: TeamUser) => userInTeam.user.id === userId + const team: TeamEntity = await this.teamsRepo.findById(scopeId); + const teamUser: TeamUserEntity | undefined = team.teamUsers.find( + (userInTeam: TeamUserEntity) => userInTeam.user.id === userId ); if (teamUser === undefined) { 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 060ae475d1b..5d12520032b 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 @@ -9,7 +9,7 @@ import { Role, RoleName, RoleReference, - Team, + TeamEntity, UserDO, VideoConferenceDO, } from '@shared/domain'; @@ -83,7 +83,7 @@ describe('VideoConferenceUc', () => { let defaultOptions: VideoConferenceOptions; const userPermissions: Map> = new Map>(); - let team: Team; + let team: TeamEntity; let user: UserDO; let defaultRole: Role; 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 c9234fd34c7..931e4810cfb 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 @@ -6,8 +6,8 @@ import { Permission, RoleName, SchoolFeatures, - Team, - TeamUser, + TeamEntity, + TeamUserEntity, UserDO, VideoConferenceDO, VideoConferenceOptionsDO, @@ -319,9 +319,9 @@ export class VideoConferenceDeprecatedUc { return roles.includes(RoleName.EXPERT); } case VideoConferenceScope.EVENT: { - const team: Team = await this.teamsRepo.findById(scopeId); - const teamUser: TeamUser | undefined = team.teamUsers.find( - (userInTeam: TeamUser) => userInTeam.user.id === currentUser.userId + const team: TeamEntity = await this.teamsRepo.findById(scopeId); + const teamUser: TeamUserEntity | undefined = team.teamUsers.find( + (userInTeam: TeamUserEntity) => userInTeam.user.id === currentUser.userId ); if (teamUser === undefined) { diff --git a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts index 1d688314f71..0f46fcde450 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts @@ -158,43 +158,106 @@ describe('VideoConferenceJoinUc', () => { }); describe('and waiting room is enabled', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - const currentUserId: string = user.id as string; - - const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; - const options: VideoConferenceOptions = { - everyAttendeeJoinsMuted: true, - everybodyJoinsAsModerator: true, - moderatorMustApproveJoinRequests: true, + describe('and everybodyJoinsAsModerator is true', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const currentUserId: string = user.id as string; + + const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; + const options: VideoConferenceOptions = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + const videoConference: VideoConferenceDO = videoConferenceDOFactory.build({ options }); + + const bbbJoinResponse: BBBResponse = { + response: { + url: 'url', + }, + } as BBBResponse; + + userService.findById.mockResolvedValue(user); + videoConferenceService.getUserRoleAndGuestStatusByUserIdForBbb.mockResolvedValue({ + role: BBBRole.VIEWER, + isGuest: true, + }); + videoConferenceService.sanitizeString.mockReturnValue(`${user.firstName} ${user.lastName}`); + bbbService.join.mockResolvedValue(bbbJoinResponse.response.url); + videoConferenceService.findVideoConferenceByScopeIdAndScope.mockResolvedValue(videoConference); + + return { user, currentUserId, scope, options, bbbJoinResponse }; }; - const videoConference: VideoConferenceDO = videoConferenceDOFactory.build({ options }); - const bbbJoinResponse: BBBResponse = { - response: { - url: 'url', - }, - } as BBBResponse; + it('should return a video conference join with url from bbb', async () => { + const { currentUserId, scope, bbbJoinResponse } = setup(); - userService.findById.mockResolvedValue(user); - videoConferenceService.getUserRoleAndGuestStatusByUserIdForBbb.mockResolvedValue({ - role: BBBRole.VIEWER, - isGuest: true, + const result: VideoConferenceJoin = await uc.join(currentUserId, scope); + + expect(result).toEqual( + expect.objectContaining>({ url: bbbJoinResponse.response.url }) + ); }); - bbbService.join.mockResolvedValue(bbbJoinResponse.response.url); - videoConferenceService.findVideoConferenceByScopeIdAndScope.mockResolvedValue(videoConference); - return { user, currentUserId, scope, options, bbbJoinResponse }; - }; + it('should call join with guest true', async () => { + const { currentUserId, scope, user } = setup(); + + await uc.join(currentUserId, scope); - it('should return a video conference join with url from bbb', async () => { - const { currentUserId, scope, bbbJoinResponse } = setup(); + expect(bbbService.join).toHaveBeenCalledWith({ + fullName: `${user.firstName} ${user.lastName}`, + meetingID: scope.id, + role: BBBRole.VIEWER, + userID: currentUserId, + guest: true, + }); + }); + }); + + describe('and everybodyJoinsAsModerator is false', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const currentUserId: string = user.id as string; + + const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; + const options: VideoConferenceOptions = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: false, + moderatorMustApproveJoinRequests: true, + }; + const videoConference: VideoConferenceDO = videoConferenceDOFactory.build({ options }); + + const bbbJoinResponse: BBBResponse = { + response: { + url: 'url', + }, + } as BBBResponse; + + userService.findById.mockResolvedValue(user); + videoConferenceService.getUserRoleAndGuestStatusByUserIdForBbb.mockResolvedValue({ + role: BBBRole.VIEWER, + isGuest: false, + }); + videoConferenceService.sanitizeString.mockReturnValue(`${user.firstName} ${user.lastName}`); + bbbService.join.mockResolvedValue(bbbJoinResponse.response.url); + videoConferenceService.findVideoConferenceByScopeIdAndScope.mockResolvedValue(videoConference); + + return { user, currentUserId, scope, options, bbbJoinResponse }; + }; - const result: VideoConferenceJoin = await uc.join(currentUserId, scope); + it('should call join with guest true', async () => { + const { currentUserId, scope, user } = setup(); - expect(result).toEqual( - expect.objectContaining>({ url: bbbJoinResponse.response.url }) - ); + await uc.join(currentUserId, scope); + + expect(bbbService.join).toHaveBeenCalledWith({ + fullName: `${user.firstName} ${user.lastName}`, + meetingID: scope.id, + role: BBBRole.VIEWER, + userID: currentUserId, + guest: true, + }); + }); }); }); }); @@ -223,10 +286,11 @@ describe('VideoConferenceJoinUc', () => { role: BBBRole.VIEWER, isGuest: false, }); + videoConferenceService.sanitizeString.mockReturnValue(`${user.firstName} ${user.lastName}`); bbbService.join.mockResolvedValue(bbbJoinResponse.response.url); videoConferenceService.findVideoConferenceByScopeIdAndScope.mockResolvedValue(videoConference); - return { currentUserId, scope, bbbJoinResponse }; + return { currentUserId, scope, bbbJoinResponse, user }; }; it('should return a video conference join with url from bbb', async () => { @@ -250,6 +314,20 @@ describe('VideoConferenceJoinUc', () => { expect.objectContaining>({ role: BBBRole.MODERATOR }) ); }); + + it('should call join with guest false', async () => { + const { currentUserId, scope, user } = setup(); + + await uc.join(currentUserId, scope); + + expect(bbbService.join).toHaveBeenCalledWith({ + fullName: `${user.firstName} ${user.lastName}`, + meetingID: scope.id, + role: BBBRole.MODERATOR, + userID: currentUserId, + guest: false, + }); + }); }); }); }); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.ts index fbd5f6bc9b8..5728a4b0da2 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.ts @@ -43,6 +43,13 @@ export class VideoConferenceJoinUc { joinBuilder.withRole(BBBRole.MODERATOR); } + if ( + videoConference.options.moderatorMustApproveJoinRequests && + !videoConference.options.everybodyJoinsAsModerator + ) { + joinBuilder.asGuest(true); + } + if (!videoConference.options.moderatorMustApproveJoinRequests && isGuest) { throw new ForbiddenException( ErrorStatus.GUESTS_CANNOT_JOIN_CONFERENCE, diff --git a/apps/server/src/shared/common/error/business.error.spec.ts b/apps/server/src/shared/common/error/business.error.spec.ts index 1b972b8eb0f..29cb16a7e02 100644 --- a/apps/server/src/shared/common/error/business.error.spec.ts +++ b/apps/server/src/shared/common/error/business.error.spec.ts @@ -89,7 +89,7 @@ describe('BusinessError', () => { const cause = { error: 'Cause' }; const error = new BusinessErrorImpl('custom message', 123, undefined, cause); const result = error.cause; - console.log(new Error(String(cause))); + expect(result).toEqual(new Error(JSON.stringify(cause))); }); }); diff --git a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts index 5b82347913d..45ca9182d3f 100644 --- a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts +++ b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts @@ -24,7 +24,7 @@ describe('DurationLoggingInterceptor', () => { await app.init(); await request(app.getHttpServer()).get('/').expect(200).expect('Schulcloud Server API'); - expect(logger.verbose).toBeCalledTimes(2); + expect(logger.log).toBeCalledTimes(2); await app.close(); }); diff --git a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.ts b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.ts index 101580bacd1..70ab32f995e 100644 --- a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.ts +++ b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.ts @@ -13,8 +13,8 @@ export class DurationLoggingInterceptor implements NestInterceptor { } intercept(context: ExecutionContext, next: CallHandler): Observable { - this.logger.verbose('Before...'); + this.logger.log('Before...'); const now = Date.now(); - return next.handle().pipe(tap(() => this.logger.verbose(`After... ${Date.now() - now}ms`))); + return next.handle().pipe(tap(() => this.logger.log(`After... ${Date.now() - now}ms`))); } } diff --git a/apps/server/src/shared/common/loggable-exception/index.ts b/apps/server/src/shared/common/loggable-exception/index.ts new file mode 100644 index 00000000000..51984c88bc6 --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/index.ts @@ -0,0 +1 @@ +export * from './not-found.loggable-exception'; diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts new file mode 100644 index 00000000000..a46c13abe70 --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { NotFoundLoggableException } from './not-found.loggable-exception'; + +describe('NotFoundLoggableException', () => { + describe('getLogMessage', () => { + const setup = () => { + const resourceName = 'School'; + const identifierName = 'id'; + const resourceId = 'schoolId'; + + const exception = new NotFoundLoggableException(resourceName, identifierName, resourceId); + + return { + exception, + resourceName, + identifierName, + resourceId, + }; + }; + + it('should log the correct message', () => { + const { exception, resourceName, identifierName, resourceId } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'NOT_FOUND', + stack: expect.any(String), + data: { + resourceName, + [identifierName]: resourceId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts new file mode 100644 index 00000000000..261f4161a30 --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts @@ -0,0 +1,27 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class NotFoundLoggableException extends NotFoundException implements Loggable { + constructor( + private readonly resourceName: string, + private readonly identifierName: string, + private readonly resourceId: EntityId + ) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'NOT_FOUND', + stack: this.stack, + data: { + resourceName: this.resourceName, + [this.identifierName]: this.resourceId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/shared/controller/index.ts b/apps/server/src/shared/controller/index.ts index aeaf541bf80..ea2de5f4684 100644 --- a/apps/server/src/shared/controller/index.ts +++ b/apps/server/src/shared/controller/index.ts @@ -1,2 +1,3 @@ export * from './dto'; export * from './transformer'; +export * from './validator'; diff --git a/apps/server/src/shared/controller/transformer/sanitize-html-transformer.spec.ts b/apps/server/src/shared/controller/transformer/sanitize-html.transformer.spec.ts similarity index 100% rename from apps/server/src/shared/controller/transformer/sanitize-html-transformer.spec.ts rename to apps/server/src/shared/controller/transformer/sanitize-html.transformer.spec.ts diff --git a/apps/server/src/shared/controller/validator/index.ts b/apps/server/src/shared/controller/validator/index.ts new file mode 100644 index 00000000000..711a5e04be2 --- /dev/null +++ b/apps/server/src/shared/controller/validator/index.ts @@ -0,0 +1 @@ +export * from './privacy-protect.validator'; diff --git a/apps/server/src/shared/controller/validator/privacy-protect.validator.ts b/apps/server/src/shared/controller/validator/privacy-protect.validator.ts new file mode 100644 index 00000000000..491ba0fd40f --- /dev/null +++ b/apps/server/src/shared/controller/validator/privacy-protect.validator.ts @@ -0,0 +1,12 @@ +import { Allow, ValidationOptions } from 'class-validator'; + +/** + * Set privacy protect context attribute in validation options. + * This can be used to detect if a property value should be obfuscated in error logs or responses. + * see e.g. ApiValidationError + */ +export function PrivacyProtect(validationOptions?: ValidationOptions): PropertyDecorator { + // We can extend the @Allow decorator safely because properties that have validations are allowed anyway. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + return Allow({ ...validationOptions, context: { ...validationOptions?.context, privacyProtected: true } }); +} diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 08e0da935d7..92f4e0776d9 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -59,9 +59,10 @@ export class ContentElementFactory { } private buildSubmissionContainer() { + const tomorrow = new Date(Date.now() + 86400000); const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), - dueDate: new Date(), + dueDate: tomorrow, children: [], createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts index 024fe09af0b..2b949f19556 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts @@ -1,5 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { submissionContainerElementFactory, submissionItemFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; import { SubmissionItem } from './submission-item.do'; import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -34,4 +35,24 @@ describe(SubmissionItem.name, () => { expect(visitor.visitSubmissionContainerElementAsync).toHaveBeenCalledWith(submissionContainerElement); }); }); + + describe('set userId', () => { + it('should set userId', () => { + const userId = new ObjectId().toHexString(); + const submissionItem = submissionItemFactory.build(); + submissionItem.userId = userId; + + expect(submissionItem.userId).toEqual(userId); + }); + }); + + describe('set completed', () => { + it('should set completed', () => { + const completed = true; + const submissionItem = submissionItemFactory.build(); + submissionItem.completed = completed; + + expect(submissionItem.completed).toEqual(completed); + }); + }); }); diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts b/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts index 25eba5e5fa3..d40a41d5dfb 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts @@ -5,19 +5,37 @@ export enum BoardRoles { EDITOR = 'editor', READER = 'reader', } +/** + deprecated: This is a temporary solution. This will be replaced with a more proper permission system. +*/ +export enum UserRoleEnum { + TEACHER = 'teacher', + STUDENT = 'student', + SUBSTITUTION_TEACHER = 'subsitution teacher', +} export interface UserBoardRoles { roles: BoardRoles[]; userId: EntityId; + userRoleEnum: UserRoleEnum; } export interface BoardDoAuthorizableProps extends AuthorizableObject { id: EntityId; users: UserBoardRoles[]; + requiredUserRole?: UserRoleEnum; } export class BoardDoAuthorizable extends DomainObject { get users(): UserBoardRoles[] { return this.props.users; } + + get requiredUserRole(): UserRoleEnum | undefined { + return this.props.requiredUserRole; + } + + set requiredUserRole(userRoleEnum: UserRoleEnum | undefined) { + this.props.requiredUserRole = userRoleEnum; + } } diff --git a/apps/server/src/shared/domain/domainobject/external-source.ts b/apps/server/src/shared/domain/domainobject/external-source.ts new file mode 100644 index 00000000000..c29c9f5ae24 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/external-source.ts @@ -0,0 +1,10 @@ +export class ExternalSource { + externalId: string; + + systemId: string; + + constructor(props: ExternalSource) { + this.externalId = props.externalId; + this.systemId = props.systemId; + } +} diff --git a/apps/server/src/shared/domain/domainobject/index.ts b/apps/server/src/shared/domain/domainobject/index.ts index f23535f46ce..b8c862ad23b 100644 --- a/apps/server/src/shared/domain/domainobject/index.ts +++ b/apps/server/src/shared/domain/domainobject/index.ts @@ -6,6 +6,6 @@ export * from './user-login-migration.do'; export * from './school.do'; export * from './user.do'; export * from './page'; -export * from './tool'; export * from './role-reference'; export * from './ltitool.do'; +export * from './external-source'; diff --git a/apps/server/src/shared/domain/domainobject/tool/config/basic-tool-config.do.ts b/apps/server/src/shared/domain/domainobject/tool/config/basic-tool-config.do.ts deleted file mode 100644 index 020fa651371..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/config/basic-tool-config.do.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ExternalToolConfigDO } from './external-tool-config.do'; -import { ToolConfigType } from './tool-config-type.enum'; - -export class BasicToolConfigDO extends ExternalToolConfigDO { - constructor(props: BasicToolConfigDO) { - super({ - type: ToolConfigType.BASIC, - baseUrl: props.baseUrl, - }); - } -} diff --git a/apps/server/src/shared/domain/domainobject/tool/config/external-tool-config.do.ts b/apps/server/src/shared/domain/domainobject/tool/config/external-tool-config.do.ts deleted file mode 100644 index d7102ee9cd1..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/config/external-tool-config.do.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ToolConfigType } from './tool-config-type.enum'; - -export abstract class ExternalToolConfigDO { - type: ToolConfigType; - - baseUrl: string; - - constructor(props: ExternalToolConfigDO) { - this.type = props.type; - this.baseUrl = props.baseUrl; - } -} diff --git a/apps/server/src/shared/domain/domainobject/tool/config/tool-config-type.enum.ts b/apps/server/src/shared/domain/domainobject/tool/config/tool-config-type.enum.ts deleted file mode 100644 index 26952e15054..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/config/tool-config-type.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum ToolConfigType { - BASIC = 'basic', - OAUTH2 = 'oauth2', - LTI11 = 'lti11', -} diff --git a/apps/server/src/shared/domain/domainobject/tool/external-tool.do.spec.ts b/apps/server/src/shared/domain/domainobject/tool/external-tool.do.spec.ts deleted file mode 100644 index 709cafa2702..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/external-tool.do.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { externalToolDOFactory } from '@shared/testing'; -import { ExternalToolDO } from './external-tool.do'; - -describe('ExternalToolDO', () => { - describe('isLti11Config', () => { - describe('when external tool with config.type Lti11 is given', () => { - it('should return true', () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.withLti11Config().buildWithId(); - - const func = () => ExternalToolDO.isLti11Config(externalToolDO.config); - - expect(func()).toBeTruthy(); - }); - }); - - describe('when external tool with config.type Lti11 is not given', () => { - it('should return false', () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); - - const func = () => ExternalToolDO.isLti11Config(externalToolDO.config); - - expect(func()).toBeFalsy(); - }); - }); - }); - - describe('isOauth2Config', () => { - describe('when external tool with config.type Oauth2 is given', () => { - it('should return true', () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.withOauth2Config().buildWithId(); - - const func = () => ExternalToolDO.isOauth2Config(externalToolDO.config); - - expect(func()).toBeTruthy(); - }); - }); - - describe('when external tool with config.type Oauth2 is not given', () => { - it('should return false', () => { - const externalToolDO: ExternalToolDO = externalToolDOFactory.buildWithId(); - - const func = () => ExternalToolDO.isOauth2Config(externalToolDO.config); - - expect(func()).toBeFalsy(); - }); - }); - }); -}); diff --git a/apps/server/src/shared/domain/domainobject/tool/external-tool.do.ts b/apps/server/src/shared/domain/domainobject/tool/external-tool.do.ts deleted file mode 100644 index fe76ef551a3..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/external-tool.do.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { BaseDO } from '../base.do'; -import { - Oauth2ToolConfigDO, - BasicToolConfigDO, - Lti11ToolConfigDO, - ExternalToolConfigDO, - ToolConfigType, -} from './config'; -import { CustomParameterDO } from './custom-parameter.do'; -import { ToolVersion } from './types'; - -export interface ExternalToolProps { - id?: string; - - name: string; - - url?: string; - - logoUrl?: string; - - config: BasicToolConfigDO | Lti11ToolConfigDO | Oauth2ToolConfigDO; - - parameters?: CustomParameterDO[]; - - isHidden: boolean; - - openNewTab: boolean; - - version: number; -} - -export class ExternalToolDO extends BaseDO implements ToolVersion { - name: string; - - url?: string; - - logoUrl?: string; - - config: BasicToolConfigDO | Lti11ToolConfigDO | Oauth2ToolConfigDO; - - parameters?: CustomParameterDO[]; - - isHidden: boolean; - - openNewTab: boolean; - - version: number; - - constructor(props: ExternalToolProps) { - super(props.id); - - this.name = props.name; - this.url = props.url; - this.logoUrl = props.logoUrl; - this.config = props.config; - this.parameters = props.parameters; - this.isHidden = props.isHidden; - this.openNewTab = props.openNewTab; - this.version = props.version; - } - - getVersion(): number { - return this.version; - } - - static isOauth2Config(config: ExternalToolConfigDO): config is Oauth2ToolConfigDO { - return ToolConfigType.OAUTH2 === config.type; - } - - static isLti11Config(config: ExternalToolConfigDO): config is Lti11ToolConfigDO { - return ToolConfigType.LTI11 === config.type; - } -} diff --git a/apps/server/src/shared/domain/domainobject/tool/index.ts b/apps/server/src/shared/domain/domainobject/tool/index.ts deleted file mode 100644 index 5473ca614fb..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './external-tool.do'; -export * from './custom-parameter.do'; -export * from './context-external-tool.do'; -export * from './custom-parameter-entry.do'; -export * from './school-external-tool.do'; -export * from './tool-configuration-status'; -export * from './school-external-tool-ref.do'; -export * from './config'; -export * from './context-ref'; -export * from './tool-reference'; -export * from './types'; diff --git a/apps/server/src/shared/domain/domainobject/tool/types/index.ts b/apps/server/src/shared/domain/domainobject/tool/types/index.ts deleted file mode 100644 index abfb1bca9fb..00000000000 --- a/apps/server/src/shared/domain/domainobject/tool/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tool-version.interface'; diff --git a/apps/server/src/shared/domain/entity/account.entity.ts b/apps/server/src/shared/domain/entity/account.entity.ts index e6495d58753..af4a5b02cf0 100644 --- a/apps/server/src/shared/domain/entity/account.entity.ts +++ b/apps/server/src/shared/domain/entity/account.entity.ts @@ -2,7 +2,7 @@ import { Entity, Property, Index } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { BaseEntityWithTimestamps } from './base.entity'; -export type IAccountProperties = Readonly>; +export type IdmAccountProperties = Readonly>; @Entity({ tableName: 'accounts' }) @Index({ properties: ['userId', 'systemId'] }) @@ -35,7 +35,7 @@ export class Account extends BaseEntityWithTimestamps { @Property({ nullable: true }) activated?: boolean; - constructor(props: IAccountProperties) { + constructor(props: IdmAccountProperties) { super(); this.username = props.username; this.password = props.password; diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 95ef500e145..d025133f6b0 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -1,5 +1,10 @@ -import { ShareToken } from '@src/modules/sharing/entity/share-token.entity'; +import { GroupEntity } from '@src/modules/group/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@src/modules/pseudonym/entity'; +import { ShareToken } from '@src/modules/sharing/entity/share-token.entity'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { ClassEntity } from '@src/modules/class/entity'; import { Account } from './account.entity'; import { CardNode, @@ -11,7 +16,6 @@ import { SubmissionItemNode, } from './boardnode'; import { BoardNode } from './boardnode/boardnode.entity'; -import { CardElement, RichTextCardElement } from './card-element.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; import { DashboardGridElementModel, DashboardModelEntity } from './dashboard.model.entity'; @@ -21,8 +25,8 @@ import { ImportUser } from './import-user.entity'; import { Board, BoardElement, - ColumnBoardTarget, ColumnboardBoardElement, + ColumnBoardTarget, LessonBoardElement, TaskBoardElement, } from './legacy-board'; @@ -36,10 +40,8 @@ import { SchoolYear } from './schoolyear.entity'; import { StorageProvider } from './storageprovider.entity'; import { Submission } from './submission.entity'; import { System } from './system.entity'; -import { TaskCard } from './task-card.entity'; import { Task } from './task.entity'; -import { Team, TeamUser } from './team.entity'; -import { ContextExternalTool, ExternalTool, SchoolExternalTool } from './tools'; +import { TeamEntity, TeamUserEntity } from './team.entity'; import { UserLoginMigration } from './user-login-migration.entity'; import { User } from './user.entity'; import { VideoConference } from './video-conference.entity'; @@ -54,17 +56,18 @@ export const ALL_ENTITIES = [ ColumnBoardNode, ColumnBoardTarget, ColumnNode, + ClassEntity, FileElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, Course, - ContextExternalTool, + ContextExternalToolEntity, CourseGroup, CourseNews, DashboardGridElementModel, DashboardModelEntity, - ExternalTool, + ExternalToolEntity, FederalState, File, ImportUser, @@ -77,7 +80,7 @@ export const ALL_ENTITIES = [ ExternalToolPseudonymEntity, Role, School, - SchoolExternalTool, + SchoolExternalToolEntity, SchoolNews, SchoolRolePermission, SchoolRoles, @@ -88,13 +91,11 @@ export const ALL_ENTITIES = [ System, Task, TaskBoardElement, - TaskCard, - CardElement, - RichTextCardElement, - Team, + TeamEntity, TeamNews, - TeamUser, + TeamUserEntity, User, UserLoginMigration, VideoConference, + GroupEntity, ]; diff --git a/apps/server/src/shared/domain/entity/card-element.entity.spec.ts b/apps/server/src/shared/domain/entity/card-element.entity.spec.ts deleted file mode 100644 index 0ef29ec8665..00000000000 --- a/apps/server/src/shared/domain/entity/card-element.entity.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { setupEntities } from '@shared/testing'; -import { CardElementType, RichTextCardElement } from '.'; -import { InputFormat, RichText } from '../types'; - -describe('RichTextCardElementEntity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - it('should have correct type', () => { - const richText = new RichText({ content: 'richText example', type: InputFormat.RICH_TEXT_CK5 }); - const richTextCardElement = new RichTextCardElement(richText); - - expect(richTextCardElement.cardElementType).toEqual(CardElementType.RichText); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/card-element.entity.ts b/apps/server/src/shared/domain/entity/card-element.entity.ts deleted file mode 100644 index fd87261a082..00000000000 --- a/apps/server/src/shared/domain/entity/card-element.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Entity, Enum, Property } from '@mikro-orm/core'; -import { RichText } from '@shared/domain/types/rich-text.types'; -import { InputFormat } from '../types/input-format.types'; -import { BaseEntityWithTimestamps } from './base.entity'; - -export enum CardElementType { - 'RichText' = 'richText', -} - -@Entity({ - discriminatorColumn: 'cardElementType', - abstract: true, - tableName: 'card-element', -}) -export abstract class CardElement extends BaseEntityWithTimestamps { - @Enum() - cardElementType!: CardElementType; -} - -@Entity({ - discriminatorValue: CardElementType.RichText, -}) -export class RichTextCardElement extends CardElement { - constructor(props: RichText) { - super(); - this.cardElementType = CardElementType.RichText; - this.inputFormat = props.type; - this.value = props.content; - } - - @Property() - value!: string; - - @Property() - inputFormat: InputFormat; -} diff --git a/apps/server/src/shared/domain/entity/course.entity.spec.ts b/apps/server/src/shared/domain/entity/course.entity.spec.ts index 166fcab9fac..2cb07340331 100644 --- a/apps/server/src/shared/domain/entity/course.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/course.entity.spec.ts @@ -214,31 +214,6 @@ describe('CourseEntity', () => { }); }); - describe('getStudentList', () => { - const setup = () => { - const students = userFactory.buildListWithId(2); - const course = courseFactory.build({ students }); - return { course, students }; - }; - it('should return the students of the course', () => { - const { course, students } = setup(); - const [student1, student2] = students; - - const result = course.getStudentsList(); - - expect(result.length).toEqual(2); - expect(result[0].id).toEqual(student1.id); - expect(result[1].id).toEqual(student2.id); - }); - it('should return an empty array if no students are in the course', () => { - const course = courseFactory.build({ students: [] }); - - const result = course.getStudentsList(); - - expect(result.length).toEqual(0); - }); - }); - describe('isUserSubstitutionTeacher is called', () => { describe('when user is a subsitution teacher', () => { const setup = () => { diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index 389ff3a67b6..99d9304b4a3 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -7,7 +7,6 @@ import { CourseGroup } from './coursegroup.entity'; import type { ILessonParent } from './lesson.entity'; import type { School } from './school.entity'; import type { ITaskParent } from './task.entity'; -import { UsersList } from './task.entity'; import type { User } from './user.entity'; export interface ICourseProperties { @@ -131,22 +130,6 @@ export class Course return ids; } - public getStudentsList(): UsersList[] { - const users = this.students.getItems(); - if (users.length) { - const usersList: UsersList[] = users.map((user) => { - return { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - }; - }); - return usersList; - } - - return []; - } - public isUserSubstitutionTeacher(user: User): boolean { const isSubstitutionTeacher = this.substitutionTeachers.contains(user); diff --git a/apps/server/src/shared/domain/entity/external-source.entity.ts b/apps/server/src/shared/domain/entity/external-source.entity.ts new file mode 100644 index 00000000000..fabe2f03d83 --- /dev/null +++ b/apps/server/src/shared/domain/entity/external-source.entity.ts @@ -0,0 +1,22 @@ +import { Embeddable, ManyToOne, Property } from '@mikro-orm/core'; +import { System } from './system.entity'; + +export interface ExternalSourceEntityProps { + externalId: string; + + system: System; +} + +@Embeddable() +export class ExternalSourceEntity { + @Property() + externalId: string; + + @ManyToOne(() => System) + system: System; + + constructor(props: ExternalSourceEntityProps) { + this.externalId = props.externalId; + this.system = props.system; + } +} diff --git a/apps/server/src/shared/domain/entity/index.ts b/apps/server/src/shared/domain/entity/index.ts index 5227c386dfb..5c8ff64a9de 100644 --- a/apps/server/src/shared/domain/entity/index.ts +++ b/apps/server/src/shared/domain/entity/index.ts @@ -2,7 +2,6 @@ export * from './account.entity'; export * from './all-entities'; export * from './base.entity'; export * from './boardnode'; -export * from './card-element.entity'; export * from './course.entity'; export * from './coursegroup.entity'; export * from './dashboard.entity'; @@ -21,10 +20,9 @@ export * from './schoolyear.entity'; export * from './storageprovider.entity'; export * from './submission.entity'; export * from './system.entity'; -export * from './task-card.entity'; export * from './task.entity'; export * from './team.entity'; -export * from './tools'; export * from './user-login-migration.entity'; export * from './user.entity'; export * from './video-conference.entity'; +export * from './external-source.entity'; diff --git a/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts b/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts index 4f9de5dc22c..3ac1d252d9d 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts @@ -3,6 +3,7 @@ import { boardFactory, columnboardBoardElementFactory, columnBoardFactory, + columnBoardTargetFactory, courseFactory, lessonBoardElementFactory, lessonFactory, @@ -173,6 +174,36 @@ describe('Board Entity', () => { expect(board.references[0].target.id).toEqual(lesson.id); }); + it('should add columnboards to board', () => { + const columnBoardTarget = columnBoardTargetFactory.buildWithId(); + const board = boardFactory.buildWithId({ references: [] }); + + board.syncBoardElementReferences([columnBoardTarget]); + + expect(board.references.count()).toEqual(1); + }); + + it('should NOT add columnboards to board that is already there', () => { + const target = columnBoardTargetFactory.buildWithId(); + const boardElement = columnboardBoardElementFactory.buildWithId({ target }); + const board = boardFactory.buildWithId({ references: [boardElement] }); + + board.syncBoardElementReferences([target]); + + expect(board.references.count()).toEqual(1); + }); + + it('should add new columnboards to the beginning of the list', () => { + const newTarget = columnBoardTargetFactory.buildWithId(); + const existingTarget = columnBoardTargetFactory.buildWithId(); + const existingElement = columnboardBoardElementFactory.buildWithId({ target: existingTarget }); + const board = boardFactory.buildWithId({ references: [existingElement] }); + + board.syncBoardElementReferences([existingTarget, newTarget]); + + expect(board.references[0].target.id).toEqual(newTarget.id); + }); + describe('when board element has an invalid type', () => { it('should throw an error', () => { const course = courseFactory.buildWithId(); diff --git a/apps/server/src/shared/domain/entity/news.entity.ts b/apps/server/src/shared/domain/entity/news.entity.ts index a234e01c670..9bdcfdd03b8 100644 --- a/apps/server/src/shared/domain/entity/news.entity.ts +++ b/apps/server/src/shared/domain/entity/news.entity.ts @@ -2,7 +2,7 @@ import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from './base.entity'; import type { Course } from './course.entity'; import type { School } from './school.entity'; -import type { Team } from './team.entity'; +import type { TeamEntity } from './team.entity'; import type { User } from './user.entity'; import { NewsTarget, NewsTargetModel } from '../types/news.types'; import { EntityId } from '../types'; @@ -123,8 +123,8 @@ export class CourseNews extends News { @Entity({ discriminatorValue: NewsTargetModel.Team }) export class TeamNews extends News { - @ManyToOne('Team') - target!: Team; + @ManyToOne('TeamEntity') + target!: TeamEntity; constructor(props: INewsProperties) { super(props); diff --git a/apps/server/src/shared/domain/entity/school.entity.ts b/apps/server/src/shared/domain/entity/school.entity.ts index 1618f5d5b67..072d7d78a21 100644 --- a/apps/server/src/shared/domain/entity/school.entity.ts +++ b/apps/server/src/shared/domain/entity/school.entity.ts @@ -23,6 +23,7 @@ export enum SchoolFeatures { LDAP_UNIVENTION_MIGRATION = 'ldapUniventionMigrationSchool', OAUTH_PROVISIONING_ENABLED = 'oauthProvisioningEnabled', SHOW_OUTDATED_USERS = 'showOutdatedUsers', + ENABLE_LDAP_SYNC_DURING_MIGRATION = 'enableLdapSyncDuringMigration', } export interface ISchoolProperties { diff --git a/apps/server/src/shared/domain/entity/task-card.entity.spec.ts b/apps/server/src/shared/domain/entity/task-card.entity.spec.ts deleted file mode 100644 index 78714be2a4b..00000000000 --- a/apps/server/src/shared/domain/entity/task-card.entity.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { richTextCardElementFactory, setupEntities, taskCardFactory } from '@shared/testing'; -import { CardElementType, RichTextCardElement } from '.'; - -describe('Task Card Entity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('getCardElements is called', () => { - describe('when task card has no card elements', () => { - it('should return an empty array', () => { - const taskCard = taskCardFactory.build(); - - expect(taskCard.getCardElements()).toEqual([]); - }); - }); - - describe('when task card has several card elements', () => { - it('should return the correct card elements', () => { - const richTextCardElement = richTextCardElementFactory.build(); - const taskCard = taskCardFactory.build({ cardElements: [richTextCardElement] }); - - const result = taskCard.getCardElements(); - expect(result.length).toEqual(1); - - const resultRichTextCardElement = result[0] as RichTextCardElement; - expect(resultRichTextCardElement.cardElementType).toEqual(CardElementType.RichText); - expect(resultRichTextCardElement.value).toEqual(richTextCardElement.value); - }); - }); - }); - - describe('isVisibleBeforeDueDate is called', () => { - describe('when task card visible at date is before due date', () => { - it('should return true', () => { - const taskCard = taskCardFactory.build(); - - expect(taskCard.isVisibleBeforeDueDate()).toEqual(true); - }); - }); - - describe('when task card visible at date is after due date', () => { - it('should return true', () => { - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - const taskCard = taskCardFactory.build({ dueDate: tomorrow, visibleAtDate: inTwoDays }); - - expect(taskCard.isVisibleBeforeDueDate()).toEqual(false); - }); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/task-card.entity.ts b/apps/server/src/shared/domain/entity/task-card.entity.ts deleted file mode 100644 index acf2d0ceccb..00000000000 --- a/apps/server/src/shared/domain/entity/task-card.entity.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Cascade, Collection, Entity, Enum, Index, ManyToMany, ManyToOne, OneToOne, Property } from '@mikro-orm/core'; -import { CardType, ICard, ICardCProps } from '../types'; -import { BaseEntityWithTimestamps } from './base.entity'; -import { CardElement } from './card-element.entity'; -import { Course } from './course.entity'; -import { Task } from './task.entity'; -import { User } from './user.entity'; - -export type ITaskCardProps = ICardCProps & { task: Task; dueDate: Date; course: Course }; - -export interface ITaskCard extends ICard { - task: Task; - dueDate: Date; - course?: Course; -} -@Entity({ - tableName: 'card', -}) -export class TaskCard extends BaseEntityWithTimestamps implements ICard, ITaskCard { - constructor(props: ITaskCardProps) { - super(); - - this.draggable = props.draggable || true; - this.cardType = props.cardType; - this.visibleAtDate = props.visibleAtDate; - this.dueDate = props.dueDate; - - this.cardElements.set(props.cardElements); - this.task = props.task; - this.cardType = CardType.Task; - this.title = props.title; - if (props.course) this.course = props.course; - Object.assign(this, { creator: props.creator }); - } - - @ManyToMany('CardElement', undefined, { fieldName: 'cardElementsIds', cascade: [Cascade.ALL] }) - cardElements = new Collection(this); - - @Enum() - cardType!: CardType; - - @Index() - @ManyToOne('User', { fieldName: 'userId' }) - creator!: User; - - @Index() - @ManyToOne('Course', { fieldName: 'courseId' }) - course!: Course; - - @Property() - draggable = true; - - @Property() - title!: string; - - @Property() - visibleAtDate: Date; - - @Property() - dueDate: Date; - - public getCardElements() { - return this.cardElements.getItems(); - } - - public isVisibleBeforeDueDate() { - return this.visibleAtDate < this.dueDate; - } - - @OneToOne({ type: 'Task', fieldName: 'taskId', eager: true, unique: true, cascade: [Cascade.ALL] }) - task!: Task; -} diff --git a/apps/server/src/shared/domain/entity/task.entity.spec.ts b/apps/server/src/shared/domain/entity/task.entity.spec.ts index cb3554f6c01..8f0d1ebd278 100644 --- a/apps/server/src/shared/domain/entity/task.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/task.entity.spec.ts @@ -9,7 +9,6 @@ import { taskFactory, userFactory, } from '@shared/testing'; -import { UsersList } from './task.entity'; describe('Task Entity', () => { beforeAll(async () => { @@ -867,41 +866,4 @@ describe('Task Entity', () => { expect(schoolId).toEqual(school.id); }); }); - - describe('getUsersList', () => { - describe('when has no users assigned', () => { - it('should return an empty array', () => { - const task = taskFactory.build(); - const result = task.getUsersList(); - expect(result).toEqual([]); - }); - }); - - describe('when task card has several users', () => { - it('should return the correct list of users', () => { - const user1 = userFactory.buildWithId(); - const user2 = userFactory.buildWithId(); - const user3 = userFactory.buildWithId(); - const user4 = userFactory.buildWithId(); - const course = courseFactory.buildWithId({ teachers: [user1] }); - const task = taskFactory.isPublished().buildWithId({ creator: user2, course, users: [user3, user4] }); - - const usersList: UsersList[] = [ - { - id: user3.id, - firstName: user3.firstName, - lastName: user3.lastName, - }, - { - id: user4.id, - firstName: user4.firstName, - lastName: user4.lastName, - }, - ]; - - const result = task.getUsersList(); - expect(result).toEqual(usersList); - }); - }); - }); }); diff --git a/apps/server/src/shared/domain/entity/task.entity.ts b/apps/server/src/shared/domain/entity/task.entity.ts index 08c33c53eed..1aa04aa45bd 100644 --- a/apps/server/src/shared/domain/entity/task.entity.ts +++ b/apps/server/src/shared/domain/entity/task.entity.ts @@ -35,14 +35,6 @@ export interface ITaskParent { getStudentIds(): EntityId[]; } -export class UsersList { - id!: string; - - firstName!: string; - - lastName!: string; -} - @Entity({ tableName: 'homeworks' }) @Index({ properties: ['private', 'dueDate', 'finished'] }) @Index({ properties: ['id', 'private'] }) @@ -74,9 +66,6 @@ export class Task extends BaseEntityWithTimestamps implements ILearnroomElement, @Property({ nullable: true }) teamSubmissions?: boolean; - @Property({ nullable: true }) - taskCard?: string; - @Index() @ManyToOne('User', { fieldName: 'teacherId' }) creator: User; @@ -96,10 +85,6 @@ export class Task extends BaseEntityWithTimestamps implements ILearnroomElement, @OneToMany('Submission', 'task') submissions = new Collection(this); - @Index() - @ManyToMany('User', undefined, { fieldName: 'userIds' }) - users = new Collection(this); - @Index() @ManyToMany('User', undefined, { fieldName: 'archived' }) finished = new Collection(this); @@ -118,7 +103,6 @@ export class Task extends BaseEntityWithTimestamps implements ILearnroomElement, this.school = props.school; this.lesson = props.lesson; this.submissions.set(props.submissions || []); - if (props.users) this.users.set(props.users); this.finished.set(props.finished || []); this.publicSubmissions = props.publicSubmissions || false; this.teamSubmissions = props.teamSubmissions || false; @@ -144,22 +128,6 @@ export class Task extends BaseEntityWithTimestamps implements ILearnroomElement, return finishedIds; } - public getUsersList(): UsersList[] { - const users = this.users.getItems(); - if (users.length) { - const usersList: UsersList[] = users.map((user) => { - return { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - }; - }); - return usersList; - } - - return []; - } - private getParent(): ITaskParent | User { const parent = this.lesson || this.course || this.creator; diff --git a/apps/server/src/shared/domain/entity/team.entity.ts b/apps/server/src/shared/domain/entity/team.entity.ts index a39b660e615..f1109d404d5 100644 --- a/apps/server/src/shared/domain/entity/team.entity.ts +++ b/apps/server/src/shared/domain/entity/team.entity.ts @@ -6,7 +6,7 @@ import { User } from './user.entity'; export interface ITeamProperties { name: string; - teamUsers?: TeamUser[]; + teamUsers?: TeamUserEntity[]; } export interface ITeamUserProperties { @@ -16,7 +16,7 @@ export interface ITeamUserProperties { } @Embeddable() -export class TeamUser { +export class TeamUserEntity { constructor(props: ITeamUserProperties) { this.userId = props.user; this.role = props.role; @@ -51,24 +51,24 @@ export class TeamUser { } @Entity({ tableName: 'teams' }) -export class Team extends BaseEntityWithTimestamps { +export class TeamEntity extends BaseEntityWithTimestamps { @Property() name: string; - @Embedded(() => TeamUser, { array: true }) - userIds: TeamUser[]; + @Embedded(() => TeamUserEntity, { array: true }) + userIds: TeamUserEntity[]; - get teamUsers(): TeamUser[] { + get teamUsers(): TeamUserEntity[] { return this.userIds; } - set teamUsers(value: TeamUser[]) { + set teamUsers(value: TeamUserEntity[]) { this.userIds = value; } constructor(props: ITeamProperties) { super(); this.name = props.name; - this.userIds = props.teamUsers ? props.teamUsers.map((teamUser) => new TeamUser(teamUser)) : []; + this.userIds = props.teamUsers ? props.teamUsers.map((teamUser) => new TeamUserEntity(teamUser)) : []; } } diff --git a/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.spec.ts b/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.spec.ts deleted file mode 100644 index 3f639709bd8..00000000000 --- a/apps/server/src/shared/domain/entity/tools/course-external-tool/context-external-tool.entity.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - BasicToolConfig, - ContextExternalTool, - CustomParameter, - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - ExternalTool, - ExternalToolConfig, - SchoolExternalTool, - ToolConfigType, -} from '@shared/domain'; -import { - contextExternalToolFactory, - externalToolFactory, - schoolExternalToolFactory, - schoolFactory, - setupEntities, -} from '@shared/testing'; - -describe('ExternalTool Entity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - it('should throw an error by empty constructor', () => { - // @ts-expect-error: Test case - const test = () => new ContextExternalTool(); - expect(test).toThrow(); - }); - - it('should create an external course Tool by passing required properties', () => { - const externalToolConfig: ExternalToolConfig = new BasicToolConfig({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }); - const customParameter: CustomParameter = new CustomParameter({ - name: 'parameterName', - displayName: 'User Friendly Name', - default: 'mock', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.CONTEXT, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - }); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - name: 'toolName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - config: externalToolConfig, - parameters: [customParameter], - isHidden: true, - openNewTab: true, - version: 1, - }); - const schoolTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, - school: schoolFactory.buildWithId(), - schoolParameters: [], - toolVersion: 1, - }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - schoolTool, - parameters: [], - toolVersion: 1, - }); - - expect(contextExternalTool instanceof ContextExternalTool).toEqual(true); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/basic-tool-config.ts b/apps/server/src/shared/domain/entity/tools/external-tool/config/basic-tool-config.ts deleted file mode 100644 index 2184a4610a1..00000000000 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/basic-tool-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Embeddable } from '@mikro-orm/core'; -import { ToolConfigType } from './tool-config-type.enum'; -import { ExternalToolConfig } from './external-tool-config'; - -@Embeddable({ discriminatorValue: ToolConfigType.BASIC }) -export class BasicToolConfig extends ExternalToolConfig { - constructor(props: BasicToolConfig) { - super(props); - this.type = ToolConfigType.BASIC; - this.baseUrl = props.baseUrl; - } -} diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/index.ts b/apps/server/src/shared/domain/entity/tools/external-tool/config/index.ts deleted file mode 100644 index 28e1505df39..00000000000 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './basic-tool-config'; -export * from './lti11-tool-config'; -export * from './oauth2-tool-config'; -export * from './lti-message-type.enum'; -export * from './external-tool-config'; diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/lti-message-type.enum.ts b/apps/server/src/shared/domain/entity/tools/external-tool/config/lti-message-type.enum.ts deleted file mode 100644 index 669e02301bd..00000000000 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/lti-message-type.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum LtiMessageType { - BASIC_LTI_LAUNCH_REQUEST = 'basic-lti-launch-request', - LTI_RESOURCE_LINK_REQUEST = 'LtiResourceLinkRequest', - LTI_DEEP_LINKING_REQUEST = 'LtiDeepLinkingRequest', -} diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/config/tool-config-type.enum.ts b/apps/server/src/shared/domain/entity/tools/external-tool/config/tool-config-type.enum.ts deleted file mode 100644 index 26952e15054..00000000000 --- a/apps/server/src/shared/domain/entity/tools/external-tool/config/tool-config-type.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum ToolConfigType { - BASIC = 'basic', - OAUTH2 = 'oauth2', - LTI11 = 'lti11', -} diff --git a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/index.ts b/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/index.ts deleted file mode 100644 index e516b885e88..00000000000 --- a/apps/server/src/shared/domain/entity/tools/external-tool/custom-parameter/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './custom-parameter'; -export * from './custom-parameter-location.enum'; -export * from './custom-parameter-scope.enum'; -export * from './custom-parameter-type.enum'; diff --git a/apps/server/src/shared/domain/entity/tools/index.ts b/apps/server/src/shared/domain/entity/tools/index.ts deleted file mode 100644 index f7f4a6cc588..00000000000 --- a/apps/server/src/shared/domain/entity/tools/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './school-external-tool/school-external-tool.entity'; -export * from './custom-parameter-entry'; -export * from './course-external-tool'; -export * from './external-tool'; diff --git a/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.spec.ts b/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.spec.ts deleted file mode 100644 index dede687e653..00000000000 --- a/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - BasicToolConfig, - CustomParameter, - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - ExternalTool, - ExternalToolConfig, - SchoolExternalTool, - ToolConfigType, -} from '@shared/domain'; -import { schoolFactory, setupEntities } from '@shared/testing'; -import { schoolExternalToolFactory } from '@shared/testing/factory/school-external-tool.factory'; - -describe('ExternalTool Entity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - it('should throw an error by empty constructor', () => { - // @ts-expect-error: Test case - const test = () => new SchoolExternalTool(); - expect(test).toThrow(); - }); - - it('should create an external school Tool by passing required properties', () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - expect(schoolExternalTool instanceof SchoolExternalTool).toEqual(true); - }); - - it('should set schoolParameters to empty when is undefined', () => { - const externalToolConfig: ExternalToolConfig = new BasicToolConfig({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - }); - const customParameter: CustomParameter = new CustomParameter({ - name: 'parameterName', - displayName: 'User Friendly Name', - default: 'mock', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - }); - const externalTool: ExternalTool = new ExternalTool({ - name: 'toolName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - config: externalToolConfig, - parameters: [customParameter], - isHidden: true, - openNewTab: true, - version: 1, - }); - const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ - tool: externalTool, - school: schoolFactory.buildWithId(), - schoolParameters: [], - toolVersion: 1, - }); - - expect(schoolExternalTool.schoolParameters).toEqual([]); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.ts b/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.ts deleted file mode 100644 index 85254a2d5de..00000000000 --- a/apps/server/src/shared/domain/entity/tools/school-external-tool/school-external-tool.entity.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Embedded, Entity, ManyToOne, Property } from '@mikro-orm/core'; -import { BaseEntityWithTimestamps } from '../../base.entity'; -import { School } from '../../school.entity'; -import { CustomParameterEntry } from '../custom-parameter-entry'; -import { ExternalTool } from '../external-tool'; - -export interface ISchoolExternalToolProperties { - tool: ExternalTool; - school: School; - schoolParameters?: CustomParameterEntry[]; - toolVersion: number; -} - -@Entity({ tableName: 'school_external_tools' }) -export class SchoolExternalTool extends BaseEntityWithTimestamps { - @ManyToOne() - tool: ExternalTool; - - @ManyToOne(() => School, { eager: true }) - school: School; - - @Embedded(() => CustomParameterEntry, { array: true }) - schoolParameters: CustomParameterEntry[]; - - @Property() - toolVersion: number; - - constructor(props: ISchoolExternalToolProperties) { - super(); - this.tool = props.tool; - this.school = props.school; - this.schoolParameters = props.schoolParameters ?? []; - this.toolVersion = props.toolVersion; - } -} diff --git a/apps/server/src/shared/domain/interface/account.ts b/apps/server/src/shared/domain/interface/account.ts index 5c4d61d06ce..5e7605f72f1 100644 --- a/apps/server/src/shared/domain/interface/account.ts +++ b/apps/server/src/shared/domain/interface/account.ts @@ -1,13 +1,13 @@ -export type IAccount = { +export type IdmAccount = { id: string; username?: string; email?: string; firstName?: string; lastName?: string; createdDate?: Date; - attRefTechnicalId?: string; - attRefFunctionalIntId?: string; - attRefFunctionalExtId?: string; + attDbcAccountId?: string; + attDbcUserId?: string; + attDbcSystemId?: string; }; -export type IAccountUpdate = Omit; +export type IdmAccountUpdate = Omit; diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index 103ca8a2ed0..3d00ef24be2 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -122,8 +122,6 @@ export enum Permission { SYSTEM_VIEW = 'SYSTEM_VIEW', TASK_DASHBOARD_TEACHER_VIEW_V3 = 'TASK_DASHBOARD_TEACHER_VIEW_V3', TASK_DASHBOARD_VIEW_V3 = 'TASK_DASHBOARD_VIEW_V3', - TASK_CARD_VIEW = 'TASK_CARD_VIEW', - TASK_CARD_EDIT = 'TASK_CARD_EDIT', TEACHER_CREATE = 'TEACHER_CREATE', TEACHER_DELETE = 'TEACHER_DELETE', TEACHER_EDIT = 'TEACHER_EDIT', diff --git a/apps/server/src/shared/domain/rules/board-do.rule.spec.ts b/apps/server/src/shared/domain/rules/board-do.rule.spec.ts index 7ad1bcefcda..3574250b67c 100644 --- a/apps/server/src/shared/domain/rules/board-do.rule.spec.ts +++ b/apps/server/src/shared/domain/rules/board-do.rule.spec.ts @@ -3,7 +3,7 @@ import { roleFactory, setupEntities, userFactory } from '@shared/testing'; import { Action } from '@src/modules'; import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { ObjectId } from 'bson'; -import { BoardDoAuthorizable, BoardRoles } from '../domainobject'; +import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '../domainobject'; import { Permission } from '../interface'; import { BoardDoRule } from './board-do.rule'; @@ -63,7 +63,7 @@ describe(BoardDoRule.name, () => { const role = roleFactory.build({ permissions: [permissionA, permissionB] }); const user = userFactory.buildWithId({ roles: [role] }); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], id: new ObjectId().toHexString(), }); @@ -93,7 +93,7 @@ describe(BoardDoRule.name, () => { const permissionA = 'a' as Permission; const user = userFactory.buildWithId(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.READER] }], + users: [{ userId: user.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }], id: new ObjectId().toHexString(), }); @@ -112,13 +112,13 @@ describe(BoardDoRule.name, () => { }); }); - describe('when user does not have the right role', () => { + describe('when user is not part of the BoardDoAuthorizable', () => { const setup = () => { const role = roleFactory.build(); const user = userFactory.buildWithId({ roles: [role] }); const userWithoutPermision = userFactory.buildWithId({ roles: [role] }); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], id: new ObjectId().toHexString(), }); @@ -142,7 +142,7 @@ describe(BoardDoRule.name, () => { const user = userFactory.buildWithId(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [] }], + users: [{ userId: user.id, roles: [], userRoleEnum: UserRoleEnum.TEACHER }], id: new ObjectId().toHexString(), }); @@ -160,5 +160,31 @@ describe(BoardDoRule.name, () => { expect(res).toBe(false); }); }); + + describe('when user has not the required userRoleEnum', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + id: new ObjectId().toHexString(), + }); + + boardDoAuthorizable.requiredUserRole = UserRoleEnum.STUDENT; + + return { user, boardDoAuthorizable }; + }; + + it('should return "false"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); }); }); diff --git a/apps/server/src/shared/domain/rules/board-do.rule.ts b/apps/server/src/shared/domain/rules/board-do.rule.ts index 91755bec5d1..575c9f0db5a 100644 --- a/apps/server/src/shared/domain/rules/board-do.rule.ts +++ b/apps/server/src/shared/domain/rules/board-do.rule.ts @@ -20,18 +20,22 @@ export class BoardDoRule implements Rule { return false; } - const result = boardDoAuthorizable.users.find(({ userId }) => userId === user.id); - if (!result) { + const userBoardRole = boardDoAuthorizable.users.find(({ userId }) => userId === user.id); + if (!userBoardRole) { return false; } - if (context.action === Action.write && result.roles.includes(BoardRoles.EDITOR)) { + if (boardDoAuthorizable.requiredUserRole && boardDoAuthorizable.requiredUserRole !== userBoardRole.userRoleEnum) { + return false; + } + + if (context.action === Action.write && userBoardRole.roles.includes(BoardRoles.EDITOR)) { return true; } if ( context.action === Action.read && - (result.roles.includes(BoardRoles.EDITOR) || result.roles.includes(BoardRoles.READER)) + (userBoardRole.roles.includes(BoardRoles.EDITOR) || userBoardRole.roles.includes(BoardRoles.READER)) ) { return true; } diff --git a/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts b/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts index 96a6fa066d9..90a25a1ca82 100644 --- a/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts +++ b/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts @@ -1,8 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { - contextExternalToolFactory, + contextExternalToolEntityFactory, roleFactory, - schoolExternalToolFactory, + schoolExternalToolEntityFactory, schoolFactory, setupEntities, userFactory, @@ -10,8 +10,11 @@ import { import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { Action } from '@src/modules/authorization/types'; -import { ContextExternalToolDO, SchoolExternalToolDO } from '../domainobject'; -import { ContextExternalTool, Role, SchoolExternalTool, User } from '../entity'; +import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { Role, User } from '../entity'; import { Permission } from '../interface'; import { ContextExternalToolRule } from './context-external-tool.rule'; @@ -40,9 +43,12 @@ describe('ContextExternalToolRule', () => { const role: Role = roleFactory.build({ permissions: [permissionA, permissionB] }); const school = schoolFactory.build(); - const schoolExternalTool: SchoolExternalTool | SchoolExternalToolDO = schoolExternalToolFactory.build({ school }); - const entity: ContextExternalTool | ContextExternalToolDO = contextExternalToolFactory.build({ - schoolTool: schoolExternalTool, + const schoolExternalToolEntity: SchoolExternalToolEntity | SchoolExternalTool = + schoolExternalToolEntityFactory.build({ + school, + }); + const entity: ContextExternalToolEntity | ContextExternalTool = contextExternalToolEntityFactory.build({ + schoolTool: schoolExternalToolEntity, }); const user: User = userFactory.build({ roles: [role], school }); return { @@ -87,7 +93,7 @@ describe('ContextExternalToolRule', () => { it('should return "false" if user has not some school', () => { const { permissionA, role } = setup(); - const entity: ContextExternalTool | ContextExternalToolDO = contextExternalToolFactory.build(); + const entity: ContextExternalToolEntity | ContextExternalTool = contextExternalToolEntityFactory.build(); const user: User = userFactory.build({ roles: [role] }); const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionA] }); diff --git a/apps/server/src/shared/domain/rules/context-external-tool.rule.ts b/apps/server/src/shared/domain/rules/context-external-tool.rule.ts index 5f1f9abafd0..35be641e550 100644 --- a/apps/server/src/shared/domain/rules/context-external-tool.rule.ts +++ b/apps/server/src/shared/domain/rules/context-external-tool.rule.ts @@ -1,26 +1,27 @@ import { Injectable } from '@nestjs/common'; import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { ContextExternalTool, User } from '../entity'; -import { ContextExternalToolDO } from '../domainobject'; +import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; +import { User } from '../entity'; @Injectable() export class ContextExternalToolRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} - public isApplicable(user: User, entity: ContextExternalTool | ContextExternalToolDO): boolean { - const isMatched: boolean = entity instanceof ContextExternalTool || entity instanceof ContextExternalToolDO; + public isApplicable(user: User, entity: ContextExternalToolEntity | ContextExternalTool): boolean { + const isMatched: boolean = entity instanceof ContextExternalToolEntity || entity instanceof ContextExternalTool; return isMatched; } public hasPermission( user: User, - entity: ContextExternalTool | ContextExternalToolDO, + entity: ContextExternalToolEntity | ContextExternalTool, context: AuthorizationContext ): boolean { let hasPermission: boolean; - if (entity instanceof ContextExternalTool) { + if (entity instanceof ContextExternalToolEntity) { hasPermission = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions) && user.school.id === entity.schoolTool.school.id; diff --git a/apps/server/src/shared/domain/rules/index.ts b/apps/server/src/shared/domain/rules/index.ts index 18ca60af557..ea72f8b4d0a 100644 --- a/apps/server/src/shared/domain/rules/index.ts +++ b/apps/server/src/shared/domain/rules/index.ts @@ -1,15 +1,15 @@ import { BoardDoRule } from './board-do.rule'; +import { ContextExternalToolRule } from './context-external-tool.rule'; import { CourseGroupRule } from './course-group.rule'; import { CourseRule } from './course.rule'; import { LessonRule } from './lesson.rule'; import { SchoolExternalToolRule } from './school-external-tool.rule'; import { SchoolRule } from './school.rule'; import { SubmissionRule } from './submission.rule'; -import { TaskCardRule } from './task-card.rule'; import { TaskRule } from './task.rule'; import { TeamRule } from './team.rule'; +import { UserLoginMigrationRule } from './user-login-migration.rule'; import { UserRule } from './user.rule'; -import { ContextExternalToolRule } from './context-external-tool.rule'; export * from './board-do.rule'; export * from './course-group.rule'; @@ -18,7 +18,6 @@ export * from './lesson.rule'; export * from './school-external-tool.rule'; export * from './school.rule'; export * from './submission.rule'; -export * from './task-card.rule'; export * from './task.rule'; export * from './team.rule'; export * from './user.rule'; @@ -31,10 +30,10 @@ export const ALL_RULES = [ SchoolRule, SubmissionRule, TaskRule, - TaskCardRule, TeamRule, UserRule, SchoolExternalToolRule, BoardDoRule, ContextExternalToolRule, + UserLoginMigrationRule, ]; diff --git a/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts b/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts index 093cca35e7f..b24ed4d0ac8 100644 --- a/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts +++ b/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts @@ -1,17 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { roleFactory, - schoolExternalToolFactory, + schoolExternalToolEntityFactory, schoolFactory, setupEntities, userFactory, - schoolExternalToolDOFactory, + schoolExternalToolFactory, } from '@shared/testing'; import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { Action } from '@src/modules/authorization/types'; -import { SchoolExternalToolDO } from '../domainobject'; -import { Role, SchoolExternalTool, User } from '../entity'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { Role, User } from '../entity'; import { Permission } from '../interface'; import { SchoolExternalToolRule } from './school-external-tool.rule'; @@ -40,7 +41,7 @@ describe('SchoolExternalToolRule', () => { const role: Role = roleFactory.build({ permissions: [permissionA, permissionB] }); const school = schoolFactory.build(); - const entity: SchoolExternalTool | SchoolExternalToolDO = schoolExternalToolFactory.build(); + const entity: SchoolExternalToolEntity | SchoolExternalTool = schoolExternalToolEntityFactory.build(); entity.school = school; const user: User = userFactory.build({ roles: [role], school }); return { @@ -85,7 +86,7 @@ describe('SchoolExternalToolRule', () => { it('should return "false" if user has not some school', () => { const { permissionA, role } = setup(); - const entity: SchoolExternalTool | SchoolExternalToolDO = schoolExternalToolDOFactory.build(); + const entity: SchoolExternalToolEntity | SchoolExternalTool = schoolExternalToolFactory.build(); const user: User = userFactory.build({ roles: [role] }); const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionA] }); diff --git a/apps/server/src/shared/domain/rules/school-external-tool.rule.ts b/apps/server/src/shared/domain/rules/school-external-tool.rule.ts index 2b805e4ba6c..bd28502faa2 100644 --- a/apps/server/src/shared/domain/rules/school-external-tool.rule.ts +++ b/apps/server/src/shared/domain/rules/school-external-tool.rule.ts @@ -1,26 +1,27 @@ import { Injectable } from '@nestjs/common'; import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { SchoolExternalTool, User } from '../entity'; -import { SchoolExternalToolDO } from '../domainobject'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { User } from '../entity'; @Injectable() export class SchoolExternalToolRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} - public isApplicable(user: User, entity: SchoolExternalTool | SchoolExternalToolDO): boolean { - const isMatched: boolean = entity instanceof SchoolExternalTool || entity instanceof SchoolExternalToolDO; + public isApplicable(user: User, entity: SchoolExternalToolEntity | SchoolExternalTool): boolean { + const isMatched: boolean = entity instanceof SchoolExternalToolEntity || entity instanceof SchoolExternalTool; return isMatched; } public hasPermission( user: User, - entity: SchoolExternalTool | SchoolExternalToolDO, + entity: SchoolExternalToolEntity | SchoolExternalTool, context: AuthorizationContext ): boolean { let hasPermission: boolean; - if (entity instanceof SchoolExternalTool) { + if (entity instanceof SchoolExternalToolEntity) { hasPermission = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions) && user.school.id === entity.school.id; diff --git a/apps/server/src/shared/domain/rules/task-card.rule.spec.ts b/apps/server/src/shared/domain/rules/task-card.rule.spec.ts deleted file mode 100644 index 3e259af4cb6..00000000000 --- a/apps/server/src/shared/domain/rules/task-card.rule.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { DeepPartial } from '@mikro-orm/core'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Role, TaskCard, User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; -import { roleFactory, setupEntities, taskCardFactory, taskFactory, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; -import { CourseGroupRule, CourseRule, LessonRule, TaskCardRule, TaskRule } from '.'; - -describe('TaskCardRule', () => { - let service: TaskCardRule; - let authorizationHelper: AuthorizationHelper; - let taskRule: DeepPartial; - let role: Role; - let user: User; - let entity: TaskCard; - const homeworkEdit = Permission.HOMEWORK_EDIT; - const homeworkView = Permission.HOMEWORK_VIEW; - - beforeAll(async () => { - await setupEntities(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [AuthorizationHelper, TaskCardRule, TaskRule, CourseRule, LessonRule, CourseGroupRule], - }).compile(); - - service = await module.get(TaskCardRule); - authorizationHelper = await module.get(AuthorizationHelper); - taskRule = await module.get(TaskRule); - }); - - beforeEach(() => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - user = userFactory.build({ roles: [role] }); - }); - - it('should call hasAllPermissions on AuthorizationHelper', () => { - entity = taskCardFactory.build({ creator: user }); - const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); - service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, []); - }); - - it('should call hasAccessToEntity on AuthorizationHelper', () => { - entity = taskCardFactory.build({ creator: user }); - const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); - service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, entity, ['creator']); - }); - - it('should call hasPermission on TaskRule', () => { - const task = taskFactory.build({ creator: user }); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission'); - service.hasPermission(user, entity, { action: Action.write, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, entity.task, { action: Action.write, requiredPermissions: [homeworkEdit] }); - }); - - describe('User is creator of the task card', () => { - describe('Access via read action', () => { - it('should return "true" if user has HOMEWORK_EDIT and HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "true" if user has HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "true" if user does not have HOMEWORK_VIEW or HOMEWORK_EDIT permission', () => { - role = roleFactory.build({ permissions: [] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "false" if required permission is not met', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { - action: Action.read, - requiredPermissions: [Permission.TASK_CARD_VIEW], - }); - expect(res).toBe(false); - }); - }); - - describe('Access via write action', () => { - it('should return "true" if user has HOMEWORK_EDIT and HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "true" if user has HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "true" if user does not have HOMEWORK_VIEW or HOMEWORK_EDIT permission', () => { - role = roleFactory.build({ permissions: [] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(true); - }); - - it('should return "false" if required permission is not met', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const creator = userFactory.build({ roles: [role] }); - const task = taskFactory.build({ creator }); - entity = taskCardFactory.build({ creator, task }); - const res = service.hasPermission(creator, entity, { - action: Action.write, - requiredPermissions: [Permission.TASK_CARD_VIEW], - }); - expect(res).toBe(false); - }); - }); - }); - - describe('User is NOT creator of the task card', () => { - describe('Access via read action', () => { - it('should return "true" if user has HOMEWORK_EDIT and HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(true); - const res = service.hasPermission(notCreator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - spy.mockRestore(); - }); - - it('should return "true" if user has HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(true); - const res = service.hasPermission(notCreator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - spy.mockRestore(); - }); - - it('should return "false" if user does not have HOMEWORK_VIEW or HOMEWORK_EDIT permission', () => { - role = roleFactory.build({ permissions: [] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(false); - const res = service.hasPermission(notCreator, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(false); - spy.mockRestore(); - }); - - it('should return "false" if required permission is not met', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(true); - const res = service.hasPermission(notCreator, entity, { - action: Action.read, - requiredPermissions: [Permission.TASK_CARD_VIEW], - }); - expect(res).toBe(false); - spy.mockRestore(); - }); - }); - - describe('Access via write action', () => { - it('should return "true" if user has HOMEWORK_EDIT and HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(true); - const res = service.hasPermission(notCreator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(true); - spy.mockRestore(); - }); - - it('should return "false" if user has HOMEWORK_VIEW permission', () => { - role = roleFactory.build({ permissions: [homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(false); - const res = service.hasPermission(notCreator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(false); - spy.mockRestore(); - }); - - it('should return "false" if user does not have HOMEWORK_VIEW or HOMEWORK_EDIT permission', () => { - role = roleFactory.build({ permissions: [] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(false); - const res = service.hasPermission(notCreator, entity, { action: Action.write, requiredPermissions: [] }); - expect(res).toBe(false); - spy.mockRestore(); - }); - - it('should return "false" if required permission is not met', () => { - role = roleFactory.build({ permissions: [homeworkEdit, homeworkView] }); - const notCreator = userFactory.build({ roles: [role] }); - const task = taskFactory.build(); - entity = taskCardFactory.build({ task }); - const spy = jest.spyOn(taskRule, 'hasPermission').mockReturnValue(true); - const res = service.hasPermission(notCreator, entity, { - action: Action.write, - requiredPermissions: [Permission.TASK_CARD_VIEW], - }); - expect(res).toBe(false); - spy.mockRestore(); - }); - }); - }); -}); diff --git a/apps/server/src/shared/domain/rules/task-card.rule.ts b/apps/server/src/shared/domain/rules/task-card.rule.ts deleted file mode 100644 index c9f35dac90b..00000000000 --- a/apps/server/src/shared/domain/rules/task-card.rule.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { TaskCard, User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { TaskRule } from './task.rule'; - -@Injectable() -export class TaskCardRule implements Rule { - constructor(private readonly authorizationHelper: AuthorizationHelper, private readonly taskRule: TaskRule) {} - - public isApplicable(user: User, entity: TaskCard): boolean { - const isMatched = entity instanceof TaskCard; - - return isMatched; - } - - public hasPermission(user: User, entity: TaskCard, context: AuthorizationContext): boolean { - const { action, requiredPermissions } = context; - const hasPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions); - const isCreator = this.authorizationHelper.hasAccessToEntity(user, entity, ['creator']); - - let hasTaskPermission = false; - - if (action === Action.read) { - hasTaskPermission = this.taskRule.hasPermission(user, entity.task, { - action, - requiredPermissions: [Permission.HOMEWORK_VIEW], - }); - } else if (action === Action.write) { - hasTaskPermission = this.taskRule.hasPermission(user, entity.task, { - action, - requiredPermissions: [Permission.HOMEWORK_EDIT], - }); - } - - const result = hasPermission && (isCreator || hasTaskPermission); - - return result; - } -} diff --git a/apps/server/src/shared/domain/rules/task.rule.spec.ts b/apps/server/src/shared/domain/rules/task.rule.spec.ts index b7c1359eb30..0fc886df84f 100644 --- a/apps/server/src/shared/domain/rules/task.rule.spec.ts +++ b/apps/server/src/shared/domain/rules/task.rule.spec.ts @@ -203,32 +203,5 @@ describe('TaskRule', () => { expect(res).toBe(false); }); }); - - describe('when task has course with 2 students and task has 1 assigned students', () => { - const setup = () => { - const role = roleFactory.build({ permissions: [permissionA, permissionB], name: RoleName.STUDENT }); - const assignedStudent = userFactory.build({ roles: [role] }); - const notAssignedStudent = userFactory.build({ roles: [role] }); - const course = courseFactory.build({ students: [assignedStudent, notAssignedStudent] }); - const task = taskFactory.build({ course, users: [assignedStudent] }); - - return { role, task, course, assignedStudent, notAssignedStudent }; - }; - - it('should return "false" if user is not assigend to task regardless of task permissions', () => { - const { notAssignedStudent, task } = setup(); - const res = service.hasPermission(notAssignedStudent, task, { - action: Action.read, - requiredPermissions: [permissionA, permissionB, permissionC], - }); - expect(res).toBe(false); - }); - - it('should return "false" if user is not assigend to task regardless of task permissions', () => { - const { assignedStudent, task } = setup(); - const res = service.hasPermission(assignedStudent, task, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - }); - }); }); }); diff --git a/apps/server/src/shared/domain/rules/task.rule.ts b/apps/server/src/shared/domain/rules/task.rule.ts index 4a3889d5809..4c358593109 100644 --- a/apps/server/src/shared/domain/rules/task.rule.ts +++ b/apps/server/src/shared/domain/rules/task.rule.ts @@ -28,17 +28,14 @@ export class TaskRule implements Rule { } const isCreator = this.authorizationHelper.hasAccessToEntity(user, entity, ['creator']); - const isAssigned = this.authorizationHelper.hasAccessToEntity(user, entity, ['users']); - const hasAssignees = entity.users.length > 0; - // task has assignees but user is not creator and not assigned -> user must have write access to parent (aka for teacher, substituteTeacher) - if (entity.isDraft() || hasAssignees) { + if (entity.isDraft()) { action = Action.write; } const hasParentPermission = this.hasParentPermission(user, entity, action); // TODO why parent permission has OR cond? - const result = isCreator || isAssigned || hasParentPermission; + const result = isCreator || hasParentPermission; return result; } diff --git a/apps/server/src/shared/domain/rules/team.rule.ts b/apps/server/src/shared/domain/rules/team.rule.ts index 94bf9611636..23ad0d55cf7 100644 --- a/apps/server/src/shared/domain/rules/team.rule.ts +++ b/apps/server/src/shared/domain/rules/team.rule.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Team, TeamUser, User } from '@shared/domain/entity'; +import { TeamEntity, TeamUserEntity, User } from '@shared/domain/entity'; import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; @@ -7,13 +7,13 @@ import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; export class TeamRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} - public isApplicable(user: User, entity: Team): boolean { - return entity instanceof Team; + public isApplicable(user: User, entity: TeamEntity): boolean { + return entity instanceof TeamEntity; } - public hasPermission(user: User, entity: Team, context: AuthorizationContext): boolean { + public hasPermission(user: User, entity: TeamEntity, context: AuthorizationContext): boolean { let hasPermission = false; - const isTeamUser = entity.teamUsers.find((teamUser: TeamUser) => teamUser.user.id === user.id); + const isTeamUser = entity.teamUsers.find((teamUser: TeamUserEntity) => teamUser.user.id === user.id); if (isTeamUser) { hasPermission = this.authorizationHelper.hasAllPermissionsByRole(isTeamUser.role, context.requiredPermissions); } diff --git a/apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts b/apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts new file mode 100644 index 00000000000..a7c6b1e5f7a --- /dev/null +++ b/apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts @@ -0,0 +1,178 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { schoolFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { Action, AuthorizationContext } from '@src/modules/authorization'; +import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; +import { UserLoginMigrationDO } from '../domainobject'; +import { Permission } from '../interface'; +import { UserLoginMigrationRule } from './user-login-migration.rule'; + +describe('UserLoginMigrationRule', () => { + let module: TestingModule; + let rule: UserLoginMigrationRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + UserLoginMigrationRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(UserLoginMigrationRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + + return { + user, + userLoginMigration, + }; + }; + + it('should return true', () => { + const { user, userLoginMigration } = setup(); + + const result = rule.isApplicable(user, userLoginMigration); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const notUserLoginMigration = userFactory.buildWithId(); + + return { + user, + notUserLoginMigration, + }; + }; + + it('should return false', () => { + const { user, notUserLoginMigration } = setup(); + + const result = rule.isApplicable(user, notUserLoginMigration as unknown as UserLoginMigrationDO); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user has all permissions and is at the school', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const user = userFactory.buildWithId({ + school: schoolFactory.buildWithId(undefined, schoolId), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(true); + + return { + user, + userLoginMigration, + context, + }; + }; + + it('should check all permissions', () => { + const { user, userLoginMigration, context } = setup(); + + rule.hasPermission(user, userLoginMigration, context); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith(user, context.requiredPermissions); + }); + + it('should return true', () => { + const { user, userLoginMigration, context } = setup(); + + const result = rule.hasPermission(user, userLoginMigration, context); + + expect(result).toEqual(true); + }); + }); + + describe('when the user has all permissions, but is at a different school', () => { + const setup = () => { + const user = userFactory.buildWithId({ + school: schoolFactory.buildWithId(undefined, new ObjectId().toHexString()), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId: new ObjectId().toHexString() }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(true); + + return { + user, + userLoginMigration, + context, + }; + }; + + it('should return false', () => { + const { user, userLoginMigration, context } = setup(); + + const result = rule.hasPermission(user, userLoginMigration, context); + + expect(result).toEqual(false); + }); + }); + + describe('when the user has no permission', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const user = userFactory.buildWithId({ + school: schoolFactory.buildWithId(undefined, schoolId), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(false); + + return { + user, + userLoginMigration, + context, + }; + }; + + it('should return false', () => { + const { user, userLoginMigration, context } = setup(); + + const result = rule.hasPermission(user, userLoginMigration, context); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/rules/user-login-migration.rule.ts b/apps/server/src/shared/domain/rules/user-login-migration.rule.ts new file mode 100644 index 00000000000..084e4d26372 --- /dev/null +++ b/apps/server/src/shared/domain/rules/user-login-migration.rule.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; +import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { UserLoginMigrationDO } from '../domainobject'; +import { User } from '../entity'; + +@Injectable() +export class UserLoginMigrationRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, entity: UserLoginMigrationDO): boolean { + const isMatched: boolean = entity instanceof UserLoginMigrationDO; + + return isMatched; + } + + public hasPermission(user: User, entity: UserLoginMigrationDO, context: AuthorizationContext): boolean { + const hasPermission: boolean = + this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions) && + user.school.id === entity.schoolId; + + return hasPermission; + } +} diff --git a/apps/server/src/shared/domain/types/card.types.ts b/apps/server/src/shared/domain/types/card.types.ts deleted file mode 100644 index a4738c3fbb1..00000000000 --- a/apps/server/src/shared/domain/types/card.types.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Collection } from '@mikro-orm/core'; -import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; -import type { User } from '@shared/domain'; -import { CardElement, CardElementType, RichTextCardElement } from '../entity/card-element.entity'; -import { InputFormat } from './input-format.types'; - -export enum CardType { - 'Task' = 'task', -} - -export type ICardCProps = { - cardElements: CardElement[]; - cardType: CardType; - creator: User; - draggable: boolean; - visibleAtDate: Date; - title: string; -}; - -export interface ICard { - cardElements: Collection; - cardType: CardType; - creator: User; - draggable: boolean; - visibleAtDate: Date; - title: string; -} - -export class CardRichTextElementResponse { - constructor(props: RichTextCardElement) { - this.value = props.value; - this.inputFormat = props.inputFormat; - } - - @ApiProperty({ - description: 'The value of the rich text card element', - }) - value!: string; - - @ApiProperty({ - description: 'The input format type of the rich text content', - enum: InputFormat, - }) - inputFormat!: InputFormat; -} - -@ApiExtraModels(CardRichTextElementResponse) -export class CardElementResponse { - @ApiProperty({ - description: 'The id of the card element', - }) - id!: string; - - @ApiProperty({ - description: 'Type of card element', - enum: CardElementType, - }) - cardElementType!: CardElementType; - - @ApiProperty({ - description: 'Content of the card element, depending on its type', - oneOf: [{ $ref: getSchemaPath(CardRichTextElementResponse) }], - }) - content!: CardRichTextElementResponse; -} diff --git a/apps/server/src/shared/domain/types/index.ts b/apps/server/src/shared/domain/types/index.ts index 9072a5fbb9b..da23af7b902 100644 --- a/apps/server/src/shared/domain/types/index.ts +++ b/apps/server/src/shared/domain/types/index.ts @@ -1,4 +1,3 @@ -export * from './card.types'; export * from './counted'; export * from './entity-id'; export * from './importuser.types'; diff --git a/apps/server/src/shared/domain/types/news.types.ts b/apps/server/src/shared/domain/types/news.types.ts index 8890901bd15..fbffcfa3710 100644 --- a/apps/server/src/shared/domain/types/news.types.ts +++ b/apps/server/src/shared/domain/types/news.types.ts @@ -1,7 +1,7 @@ import { EntityId } from './entity-id'; import type { Course } from '../entity/course.entity'; import type { School } from '../entity/school.entity'; -import type { Team } from '../entity/team.entity'; +import type { TeamEntity } from '../entity/team.entity'; export enum NewsTargetModel { 'School' = 'schools', @@ -26,4 +26,4 @@ export interface INewsScope { unpublished?: boolean; } -export type NewsTarget = School | Team | Course; +export type NewsTarget = School | TeamEntity | Course; diff --git a/apps/server/src/shared/domain/types/task.types.ts b/apps/server/src/shared/domain/types/task.types.ts index 36e142c8fa0..5d33ef4a9d3 100644 --- a/apps/server/src/shared/domain/types/task.types.ts +++ b/apps/server/src/shared/domain/types/task.types.ts @@ -6,26 +6,22 @@ interface ITask { descriptionInputFormat?: InputFormat; availableDate?: Date; dueDate?: Date; - taskCard?: string; } export interface ITaskUpdate extends ITask { courseId?: string; lessonId?: string; - usersIds?: string[]; } export interface ITaskCreate extends ITask { courseId?: string; lessonId?: string; - usersIds?: string[]; } export interface ITaskProperties extends ITask { course?: Course; lesson?: Lesson; creator: User; - users?: User[]; school: School; finished?: User[]; private?: boolean; diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts index b60096973ca..6a02415a82b 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts @@ -57,10 +57,10 @@ describe('AntivirusService', () => { return { requestToken, expectedParams }; }; - it('should send given data to queue', () => { + it('should send given data to queue', async () => { const { requestToken, expectedParams } = setup(); - service.send(requestToken); + await service.send(requestToken); expect(amqpConnection.publish).toHaveBeenCalledWith(...expectedParams); }); @@ -70,17 +70,15 @@ describe('AntivirusService', () => { const setup = () => { const requestToken = uuid(); - amqpConnection.publish.mockImplementationOnce(() => { - throw new Error('fail'); - }); + amqpConnection.publish.mockRejectedValueOnce(new Error('fail')); return { requestToken }; }; - it('should throw with InternalServerErrorException by error', () => { + it('should throw with InternalServerErrorException by error', async () => { const { requestToken } = setup(); - expect(() => service.send(requestToken)).toThrow(InternalServerErrorException); + await expect(() => service.send(requestToken)).rejects.toThrowError(InternalServerErrorException); }); }); }); diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.ts index 069ff1a1497..c3c38494853 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.ts @@ -1,5 +1,6 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ErrorUtils } from '@src/core/error/utils'; import { API_VERSION_PATH, FilesStorageInternalActions } from '@src/modules/files-storage/files-storage.const'; interface AntivirusServiceOptions { @@ -16,13 +17,13 @@ export class AntivirusService { @Inject('ANTIVIRUS_SERVICE_OPTIONS') private readonly options: AntivirusServiceOptions ) {} - public send(requestToken: string | undefined) { + public async send(requestToken: string | undefined): Promise { try { if (this.options.enabled && requestToken) { const downloadUri = this.getUrl(FilesStorageInternalActions.downloadBySecurityToken, requestToken); const callbackUri = this.getUrl(FilesStorageInternalActions.updateSecurityStatus, requestToken); - this.amqpConnection.publish( + await this.amqpConnection.publish( this.options.exchange, this.options.routingKey, { download_uri: downloadUri, callback_uri: callbackUri }, @@ -30,7 +31,7 @@ export class AntivirusService { ); } } catch (err) { - throw new InternalServerErrorException(err, AntivirusService.name); + throw new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(err, 'AntivirusService:send')); } } diff --git a/apps/server/src/shared/infra/cache/cache.module.ts b/apps/server/src/shared/infra/cache/cache.module.ts index ed6aad058b7..897e93926d1 100644 --- a/apps/server/src/shared/infra/cache/cache.module.ts +++ b/apps/server/src/shared/infra/cache/cache.module.ts @@ -1,6 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { CacheModule, Module } from '@nestjs/common'; -import { CacheModuleOptions } from '@nestjs/common/cache/interfaces/cache-module.interface'; +import { CacheModule, CacheModuleOptions } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; import { create } from 'cache-manager-redis-store'; import { RedisClient } from 'redis'; diff --git a/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts b/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts index b20a3f6f4f1..43ca5ad06d4 100644 --- a/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts +++ b/apps/server/src/shared/infra/calendar/service/calendar.service.spec.ts @@ -1,13 +1,14 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +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 { createMock, DeepMocked } from '@golevelup/ts-jest'; import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; -import { HttpService } from '@nestjs/axios'; -import { of, throwError } from 'rxjs'; import { ICalendarEvent } from '@shared/infra/calendar/interface/calendar-event.interface'; -import { AxiosResponse } from 'axios'; -import { InternalServerErrorException } from '@nestjs/common'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; +import { axiosResponseFactory } from '@shared/testing'; +import { AxiosResponse } from 'axios'; +import { of, throwError } from 'rxjs'; describe('CalendarServiceSpec', () => { let module: TestingModule; @@ -65,13 +66,9 @@ describe('CalendarServiceSpec', () => { }, ], }; - const axiosResponse: AxiosResponse = { + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ data: event, - status: 0, - statusText: 'statusText', - headers: {}, - config: {}, - }; + }); httpService.get.mockReturnValue(of(axiosResponse)); calendarMapper.mapToDto.mockReturnValue({ title, teamId }); @@ -92,7 +89,6 @@ describe('CalendarServiceSpec', () => { await expect(service.findEvent('invalid userId', 'invalid eventId')).rejects.toThrow( InternalServerErrorException ); - await expect(service.findEvent('invalid userId', 'invalid eventId')).rejects.toThrow(error); }); }); }); diff --git a/apps/server/src/shared/infra/calendar/service/calendar.service.ts b/apps/server/src/shared/infra/calendar/service/calendar.service.ts index bb7cc380e95..b79564634a5 100644 --- a/apps/server/src/shared/infra/calendar/service/calendar.service.ts +++ b/apps/server/src/shared/infra/calendar/service/calendar.service.ts @@ -1,12 +1,13 @@ +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 { firstValueFrom, Observable } from 'rxjs'; +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 { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Observable, firstValueFrom } from 'rxjs'; import { URL, URLSearchParams } from 'url'; -import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { CalendarMapper } from '@shared/infra/calendar/mapper/calendar.mapper'; -import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto'; import { ICalendarEvent } from '../interface/calendar-event.interface'; @Injectable() @@ -35,7 +36,10 @@ export class CalendarService { ) .then((resp: AxiosResponse) => this.calendarMapper.mapToDto(resp.data)) .catch((error) => { - throw new InternalServerErrorException(error); + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'CalendarService:findEvent') + ); }); } diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts index e6e4e46b718..5aa4cb8b09d 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.spec.ts @@ -1,16 +1,17 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +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 { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { LegacyLogger } from '@src/core/logger'; -import { NextcloudClient } from '@shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client'; -import { NotFoundException, NotImplementedException, UnprocessableEntityException } from '@nestjs/common'; -import { ObjectId } from '@mikro-orm/mongodb'; import { + GroupUsers, GroupfoldersCreated, GroupfoldersFolder, - GroupUsers, Meta, NextcloudGroups, OcsResponse, @@ -42,15 +43,10 @@ function createOcsResponse(data: T, meta: Meta = defaultMetadata): }; } -function createAxiosResponse(data: T): AxiosResponse { - return { +const createAxiosResponse = (data: unknown) => + axiosResponseFactory.build({ data, - status: 0, - statusText: '', - headers: {}, - config: {}, - }; -} + }); function createObservable(data: T): Observable> { return of(createAxiosResponse(data)); diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts index 14ed4ae22aa..75caf1bc0e4 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.client.ts @@ -1,3 +1,5 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HttpService } from '@nestjs/axios'; import { Inject, Injectable, @@ -5,21 +7,20 @@ import { NotImplementedException, UnprocessableEntityException, } from '@nestjs/common'; -import { firstValueFrom, Observable } from 'rxjs'; -import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import { parseInt } from 'lodash'; -import { LegacyLogger } from '@src/core/logger'; -import { HttpService } from '@nestjs/axios'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { + GroupUsers, GroupfoldersCreated, GroupfoldersFolder, - GroupUsers, Meta, NextcloudGroups, OcsResponse, SuccessfulRes, } from '@shared/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'; +import { parseInt } from 'lodash'; +import { Observable, firstValueFrom } from 'rxjs'; @Injectable() export class NextcloudClient { @@ -55,7 +56,10 @@ export class NextcloudClient { request, (data: NextcloudGroups) => data.groups[0], (error) => { - throw new NotFoundException(error, `Group ${groupName} not found in Nextcloud!`); + throw new NotFoundException( + `Group ${groupName} not found in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:findGroupId') + ); } ); } @@ -78,7 +82,10 @@ export class NextcloudClient { throw Error(); }, (error) => { - throw new NotFoundException(error, `Group with TeamId of ${teamId} not found in Nextcloud!`); + throw new NotFoundException( + `Group with TeamId of ${teamId} not found in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:findGroupIdByTeamId') + ); } ); } @@ -99,7 +106,10 @@ export class NextcloudClient { request, () => this.logger.log(`Successfully created group with group id: ${groupId} in Nextcloud`), (error) => { - throw new UnprocessableEntityException(error, `Group "${groupId}" could not be created in Nextcloud!`); + throw new UnprocessableEntityException( + `Group "${groupId}" could not be created in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:createGroup') + ); } ); } @@ -116,7 +126,10 @@ export class NextcloudClient { request, () => this.logger.log(`Successfully removed group with group id: ${groupId} in Nextcloud`), (error) => { - throw new UnprocessableEntityException(error, `Group "${groupId}" could not be deleted in Nextcloud!`); + throw new UnprocessableEntityException( + `Group "${groupId}" could not be deleted in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:deleteGroup') + ); } ); } @@ -137,7 +150,10 @@ export class NextcloudClient { request, () => this.logger.log(`Successfully renamed group with group id: ${groupId} in Nextcloud`), (error) => { - throw new UnprocessableEntityException(error, `Group "${groupId}" could not be renamed in Nextcloud!`); + throw new UnprocessableEntityException( + `Group "${groupId}" could not be renamed in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:renameGroup') + ); } ); } @@ -160,7 +176,10 @@ export class NextcloudClient { throw new NotImplementedException(); }, (error) => { - throw new NotImplementedException(error); + throw new NotImplementedException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:setGroupPermission') + ); } ); } @@ -180,7 +199,10 @@ export class NextcloudClient { request, (data: GroupfoldersFolder[]) => data[0].folder_id, (error) => { - throw new NotFoundException(error, `Folder for ${groupId} not found in Nextcloud!`); + throw new NotFoundException( + `Folder for ${groupId} not found in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:findGroupFolderIdForGroupId') + ); } ); } @@ -202,7 +224,10 @@ export class NextcloudClient { throw Error(); }, (error) => { - throw new NotFoundException(error, `Folder could not be deleted in Nextcloud!`); + throw new NotFoundException( + `Folder could not be deleted in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:deleteGroupFolder') + ); } ); } @@ -226,7 +251,10 @@ export class NextcloudClient { return folderId; }, (error) => { - throw new UnprocessableEntityException(error, `Groupfolder wit name "${folderName}" could not be created!`); + throw new UnprocessableEntityException( + `Groupfolder wit name "${folderName}" could not be created!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:createGroupFolder') + ); } ); } @@ -248,7 +276,10 @@ export class NextcloudClient { this.logger.log(`Successfully added group: ${groupId} to folder with folder id: ${folderId} in Nextcloud`); }, (error) => { - throw new UnprocessableEntityException(error, `Group "${groupId}" could not be deleted in Nextcloud!`); + throw new UnprocessableEntityException( + `Group "${groupId}" could not be deleted in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:addAccessToGroupFolder') + ); } ); } @@ -269,7 +300,10 @@ export class NextcloudClient { return data.users; }, (error) => { - throw new UnprocessableEntityException(error, `Could not fetch users in group: ${groupId} in Nextcloud!`); + throw new UnprocessableEntityException( + `Could not fetch users in group: ${groupId} in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:getGroupUsers') + ); } ); } @@ -292,8 +326,8 @@ export class NextcloudClient { }, (error) => { throw new UnprocessableEntityException( - error, - `User: "${userId}" could not be added to group: ${groupId} in Nextcloud!` + `User: "${userId}" could not be added to group: ${groupId} in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:addUserToGroup') ); } ); @@ -316,8 +350,8 @@ export class NextcloudClient { }, (error) => { throw new UnprocessableEntityException( - error, - `User: "${userId}" could not be removed from group: ${groupId} in Nextcloud!` + `User: "${userId}" could not be removed from group: ${groupId} in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:removeUserFromGroup') ); } ); @@ -340,7 +374,10 @@ export class NextcloudClient { this.logger.log(`Successfully renamed folder with folder id: ${folderId} in Nextcloud`); }, (error) => { - throw new UnprocessableEntityException(error, `Groupfolder "${folderId}" could not be renamed in Nextcloud!`); + throw new UnprocessableEntityException( + `Groupfolder "${folderId}" could not be renamed in Nextcloud!`, + ErrorUtils.createHttpExceptionOptions(error, 'NextcloudClient:changeGroupFolderName') + ); } ); } diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts index c9128ba780e..8efd3ff9a7d 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; 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'; @@ -10,8 +10,8 @@ import { LtiToolRepo } from '@shared/repo'; import { ltiToolDOFactory, pseudonymFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { TeamDto, TeamUserDto } from '@src/modules/collaborative-storage/services/dto/team.dto'; -import { ExternalToolService } from '@src/modules/tool/external-tool/service'; import { PseudonymService } from '@src/modules/pseudonym'; +import { ExternalToolService } from '@src/modules/tool/external-tool/service'; import { UserService } from '@src/modules/user'; class NextcloudStrategySpec extends NextcloudStrategy { @@ -385,7 +385,7 @@ describe('NextCloudStrategy', () => { const userDo: UserDO = userDoFactory.build({ id: user.id }); const teamUsers: TeamUserDto[] = [{ userId: user.id, schoolId: user.school.id, roleId: user.roles[0].id }]; - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ + const pseudonym: Pseudonym = pseudonymFactory.build({ userId: user.id, toolId: nextcloudTool.id as string, pseudonym: `ps${user.id}`, @@ -465,7 +465,7 @@ describe('NextCloudStrategy', () => { const user: User = userFactory.withRoleByName(RoleName.TEAMMEMBER).buildWithId(); const teamUsers: TeamUserDto[] = []; - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ + const pseudonym: Pseudonym = pseudonymFactory.build({ userId: user.id, toolId: nextcloudTool.id as string, pseudonym: `ps${user.id}`, @@ -531,7 +531,7 @@ describe('NextCloudStrategy', () => { { userId: 'invalidId', schoolId: 'someSchool', roleId: 'someRole' }, ]; - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ + const pseudonym: Pseudonym = pseudonymFactory.build({ userId: user.id, toolId: nextcloudTool.id as string, pseudonym: `ps${user.id}`, @@ -570,7 +570,7 @@ describe('NextCloudStrategy', () => { const user: User = userFactory.withRoleByName(RoleName.TEAMMEMBER).buildWithId(); const teamUsers: TeamUserDto[] = [{ userId: user.id, schoolId: user.school.id, roleId: user.roles[0].id }]; - const pseudonym: Pseudonym = pseudonymFactory.buildWithId({ + const pseudonym: Pseudonym = pseudonymFactory.build({ userId: user.id, toolId: nextcloudTool.id as string, pseudonym: `ps${user.id}`, diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts index 10d9a03f777..16f8c62aff3 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts @@ -1,5 +1,5 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { ExternalToolDO, Pseudonym, UserDO } from '@shared/domain/'; +import { Pseudonym, UserDO } from '@shared/domain/'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { LtiToolRepo } from '@shared/repo/ltitool/'; import { LegacyLogger } from '@src/core/logger'; @@ -7,6 +7,7 @@ import { TeamDto, TeamUserDto } from '@src/modules/collaborative-storage'; import { PseudonymService } from '@src/modules/pseudonym'; import { UserService } from '@src/modules/user'; import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { TeamRolePermissionsDto } from '../../dto/team-role-permissions.dto'; import { ICollaborativeStorageStrategy } from '../base.interface.strategy'; import { NextcloudClient } from './nextcloud.client'; @@ -127,7 +128,7 @@ export class NextcloudStrategy implements ICollaborativeStorageStrategy { */ protected async updateTeamUsersInGroup(groupId: string, teamUsers: TeamUserDto[]): Promise { const groupUserIds: string[] = await this.client.getGroupUsers(groupId); - const nextcloudTool: ExternalToolDO | LtiToolDO = await this.findNextcloudTool(); + const nextcloudTool: ExternalTool | LtiToolDO = await this.findNextcloudTool(); let convertedTeamUserIds: string[] = await Promise.all[]>( // The Oauth authentication generates a pseudonym which will be used from external systems as identifier @@ -154,8 +155,8 @@ export class NextcloudStrategy implements ICollaborativeStorageStrategy { ]); } - private async findNextcloudTool(): Promise { - const tool: ExternalToolDO | null = await this.externalToolService.findExternalToolByName( + private async findNextcloudTool(): Promise { + const tool: ExternalTool | null = await this.externalToolService.findExternalToolByName( this.client.oidcInternalName ); diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts b/apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts index eaace67cd61..09cbb79c9ff 100644 --- a/apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts +++ b/apps/server/src/shared/infra/console/console-writer/console-writer.service.spec.ts @@ -20,14 +20,4 @@ describe('ConsoleWriterService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); - describe('when using info on console writer', () => { - it('should call spinner info with same input text', () => { - // eslint-disable-next-line @typescript-eslint/dot-notation - const spinnerSpy = jest.spyOn(service['spinner'], 'info'); - const someRandomText = 'random text'; - service.info(someRandomText); - expect(spinnerSpy).toHaveBeenCalledWith(someRandomText); - spinnerSpy.mockReset(); - }); - }); }); diff --git a/apps/server/src/shared/infra/console/console-writer/console-writer.service.ts b/apps/server/src/shared/infra/console/console-writer/console-writer.service.ts index 62aadb9ba4d..f02f7b8785d 100644 --- a/apps/server/src/shared/infra/console/console-writer/console-writer.service.ts +++ b/apps/server/src/shared/infra/console/console-writer/console-writer.service.ts @@ -1,15 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { createSpinner } from 'nestjs-console'; -import ora from 'ora'; @Injectable() -/** - * Console writer service using ora spinner internally. - */ export class ConsoleWriterService { - private spinner: ora.Ora = createSpinner(); - info(text: string): void { - this.spinner.info(text); + // eslint-disable-next-line no-console + console.info('Info:', text); } } diff --git a/apps/server/src/shared/infra/identity-management/identity-management.service.ts b/apps/server/src/shared/infra/identity-management/identity-management.service.ts index dae5a657515..882557196e6 100644 --- a/apps/server/src/shared/infra/identity-management/identity-management.service.ts +++ b/apps/server/src/shared/infra/identity-management/identity-management.service.ts @@ -1,4 +1,4 @@ -import { Counted, IAccount, IAccountUpdate } from '@shared/domain'; +import { Counted, IdmAccount, IdmAccountUpdate } from '@shared/domain'; export type SearchOptions = { exact?: boolean; @@ -14,7 +14,7 @@ export abstract class IdentityManagementService { * @param [password] the account's password (optional) * @returns the account id if created successfully */ - abstract createAccount(account: IAccountUpdate, password?: string | undefined): Promise; + abstract createAccount(account: IdmAccountUpdate, password?: string | undefined): Promise; /** * Update an existing account's details. @@ -23,7 +23,7 @@ export abstract class IdentityManagementService { * @param account the account data to be applied. * @returns the account id if updated successfully */ - abstract updateAccount(accountId: string, account: IAccountUpdate): Promise; + abstract updateAccount(accountId: string, account: IdmAccountUpdate): Promise; /** * Update an existing account's password. @@ -40,23 +40,23 @@ export abstract class IdentityManagementService { * @param accountId the account to be loaded. * @returns the account if exists */ - abstract findAccountById(accountId: string): Promise; + abstract findAccountById(accountId: string): Promise; /** - * Load a specific account by its technical reference id. + * Load a specific account by its dbc account id. * - * @param accountTecRefId the account to be loaded. + * @param accountDbcAccountId the account to be loaded. * @returns the account if exists */ - abstract findAccountByTecRefId(accountTecRefId: string): Promise; + abstract findAccountByDbcAccountId(accountDbcAccountId: string): Promise; /** - * Load a specific account by its functional internal reference id. + * Load a specific account by its dbc user id. * - * @param accountTecRefId the account to be loaded. + * @param accountDbcUserId the account to be loaded. * @returns the account if exists */ - abstract findAccountByFctIntId(accountFctIntId: string): Promise; + abstract findAccountByDbcUserId(accountDbcUserId: string): Promise; /** * Loads the account with the specific username. @@ -64,14 +64,14 @@ export abstract class IdentityManagementService { * @param options the search options to be applied. * @returns the found accounts (might be empty). */ - abstract findAccountsByUsername(username: string, options?: SearchOptions): Promise>; + abstract findAccountsByUsername(username: string, options?: SearchOptions): Promise>; /** * Load all accounts. * * @returns an array of all accounts (might be empty) */ - abstract getAllAccounts(): Promise; + abstract getAllAccounts(): Promise; /** * Deletes an account from the identity management. diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts index b682b23310d..baf86c58ccf 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts @@ -31,9 +31,9 @@ describe('KeycloakConfigurationService Integration', () => { firstName: undefined, lastName: undefined, attributes: { - refTechnicalId: account._id, - refFunctionalIntId: undefined, - refFunctionalExtId: account.systemId, + dbcAccountId: account._id, + dbcUserId: undefined, + dbcSystemId: account.systemId, }, }); await keycloak.users.resetPassword({ diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts index f59ba935254..b64f69d5043 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts @@ -57,9 +57,9 @@ export class KeycloakMigrationService { }, ], attributes: { - refTechnicalId: account.id, - refFunctionalIntId: account.userId, - refFunctionalExtId: account.systemId, + dbcAccountId: account.id, + dbcUserId: account.userId, + dbcSystemId: account.systemId, }, }; const kc = await this.kcAdmin.callKcAdminClient(); diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts index b1e127a6a45..8d411a5356a 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts @@ -66,9 +66,9 @@ describe('KeycloakSeedService', () => { email: 'john.doe@email.tld', username: 'john.doe', attributes: { - refTechnicalId: '1tec', - refFunctionalIntId: '1int', - refFunctionalExtId: 'sysId', + dbcAccountId: 'accId', + dbcUserId: 'usrId', + dbcSystemId: 'sysId', }, }, { @@ -153,10 +153,10 @@ describe('KeycloakSeedService', () => { validAccountsNoDuplicates = [ { - _id: { $oid: '1tec' }, + _id: { $oid: 'accId' }, username: users[0].username ?? missingUsername, password: '', - userId: { $oid: '1int' }, + userId: { $oid: 'usrId' }, systemId: 'sysId', }, { _id: { $oid: '2' }, username: users[1].username ?? missingUsername, password: '', userId: { $oid: '2' } }, @@ -170,7 +170,7 @@ describe('KeycloakSeedService', () => { jsonAccounts = [...validAccounts, { _id: { $oid: '4' }, username: 'NoUser', password: '', userId: { $oid: '99' } }]; jsonUsers = [ - { _id: { $oid: '1int' }, firstName: users[0].firstName ?? missingFirstName, lastName: '', email: '' }, + { _id: { $oid: 'usrId' }, firstName: users[0].firstName ?? missingFirstName, lastName: '', email: '' }, { _id: { $oid: '2' }, firstName: users[1].firstName ?? missingFirstName, lastName: '', email: '' }, { _id: { $oid: '3' }, firstName: users[2].firstName ?? missingFirstName, lastName: '', email: '' }, { _id: { $oid: '4' }, firstName: 'NoAccount', lastName: '', email: '' }, diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts index 18795232d6e..078601d3248 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts @@ -64,9 +64,9 @@ export class KeycloakSeedService { }, ], attributes: { - refTechnicalId: account._id.$oid, - refFunctionalIntId: account.userId.$oid, - refFunctionalExtId: account.systemId, + dbcAccountId: account._id.$oid, + dbcUserId: account.userId.$oid, + dbcSystemId: account.systemId, }, }; const kc = await this.kcAdmin.callKcAdminClient(); diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts index 472df37fbcc..b4e6535011d 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.integration.spec.ts @@ -3,7 +3,7 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRep import { ObjectId } from '@mikro-orm/mongodb'; import { HttpModule } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { IAccount, IAccountUpdate } from '@shared/domain'; +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 { ServerModule } from '@src/modules/server'; @@ -19,13 +19,13 @@ describe('KeycloakIdentityManagementService Integration', () => { let isKeycloakReachable: boolean; const testRealm = `test-realm-${v1().toString()}`; - const testAccount: IAccount = { + const testAccount: IdmAccount = { id: new ObjectId().toString(), email: 'john.doe@mail.tld', username: 'john.doe@mail.tld', firstName: 'John', lastName: 'Doe', - attRefTechnicalId: new ObjectId().toString(), + attDbcAccountId: new ObjectId().toString(), }; const createAccount = async (attributeName?: string, attributeValue?: unknown): Promise => { const { id } = await keycloak.users.create({ @@ -33,9 +33,9 @@ describe('KeycloakIdentityManagementService Integration', () => { firstName: testAccount.firstName, lastName: testAccount.lastName, attributes: { - refTechnicalId: testAccount.attRefTechnicalId, - refFunctionalIntId: undefined, - refFunctionalExtId: undefined, + dbcAccountId: testAccount.attDbcAccountId, + dbcUserId: undefined, + dbcSystemId: undefined, attributeName: attributeValue, }, }); @@ -85,7 +85,7 @@ describe('KeycloakIdentityManagementService Integration', () => { firstName: testAccount.firstName, lastName: testAccount.lastName, attributes: { - refTechnicalId: [testAccount.attRefTechnicalId], + dbcAccountId: [testAccount.attDbcAccountId], }, }), ]) @@ -94,7 +94,7 @@ describe('KeycloakIdentityManagementService Integration', () => { it('should update an account', async () => { if (!isKeycloakReachable) return; - const newAccount: IAccountUpdate = { + const newAccount: IdmAccountUpdate = { email: 'jane.doe@mail.tld', username: 'jane.doe@mail.tld', firstName: 'Jane', @@ -140,7 +140,7 @@ describe('KeycloakIdentityManagementService Integration', () => { const account = await idmService.findAccountById(idmId); expect(account).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: idmId, username: testAccount.username, firstName: testAccount.firstName, @@ -156,7 +156,7 @@ describe('KeycloakIdentityManagementService Integration', () => { expect(account).toEqual( expect.arrayContaining([ - expect.objectContaining>({ + expect.objectContaining>({ username: testAccount.username, firstName: testAccount.firstName, lastName: testAccount.lastName, diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts index d98035b46f7..31c33540983 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.spec.ts @@ -3,7 +3,7 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-ad import { Users } from '@keycloak/keycloak-admin-client/lib/resources/users'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; -import { IAccount } from '@shared/domain/interface/account'; +import { IdmAccount } from '@shared/domain/interface/account'; import { IdentityManagementService } from '../../identity-management.service'; import { KeycloakSettings } from '../../keycloak-administration/interface/keycloak-settings.interface'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; @@ -159,7 +159,7 @@ describe('KeycloakIdentityManagementService', () => { const ret = await idm.findAccountById(mockedAccount1.id); expect(ret).not.toBeNull(); expect(ret).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: '', }) ); @@ -188,7 +188,7 @@ describe('KeycloakIdentityManagementService', () => { expect(ret).not.toBeNull(); expect(ret).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: ret.id, createdDate: date, }) @@ -201,20 +201,20 @@ describe('KeycloakIdentityManagementService', () => { kcUsersMock.findOne.mockResolvedValueOnce({ ...mockedAccount1, attributes: { - refTechnicalId: 'tecId', - refFunctionalIntId: 'fctIntId', - refFunctionalExtId: 'fctExtId', + dbcAccountId: 'dbcAccountId', + dbcUserId: 'dbcUserId', + dbcSystemId: 'dbcSystemId', }, }); const ret = await idm.findAccountById(mockedAccount1.id); expect(ret).not.toBeNull(); expect(ret).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: ret.id, - attRefTechnicalId: 'tecId', - attRefFunctionalIntId: 'fctIntId', - attRefFunctionalExtId: 'fctExtId', + attDbcAccountId: 'dbcAccountId', + attDbcUserId: 'dbcUserId', + attDbcSystemId: 'dbcSystemId', }) ); }); @@ -225,20 +225,20 @@ describe('KeycloakIdentityManagementService', () => { kcUsersMock.findOne.mockResolvedValueOnce({ ...mockedAccount1, attributes: { - refTechnicalId: ['tecId', 'ignore'], - refFunctionalIntId: ['fctIntId', 'ignore', 'ignore'], - refFunctionalExtId: ['fctExtId'], + dbcAccountId: ['dbcAccountId', 'ignore'], + dbcUserId: ['dbcUserId', 'ignore', 'ignore'], + dbcSystemId: ['dbcSystemId'], }, }); const ret = await idm.findAccountById(mockedAccount1.id); expect(ret).not.toBeNull(); expect(ret).toEqual( - expect.objectContaining({ + expect.objectContaining({ id: ret.id, - attRefTechnicalId: 'tecId', - attRefFunctionalIntId: 'fctIntId', - attRefFunctionalExtId: 'fctExtId', + attDbcAccountId: 'dbcAccountId', + attDbcUserId: 'dbcUserId', + attDbcSystemId: 'dbcSystemId', }) ); }); @@ -278,10 +278,10 @@ describe('KeycloakIdentityManagementService', () => { }); }); - describe('findAccountByTecRefId', () => { - it('should find an existing account by technical reference id', async () => { + describe('findAccountByDbcAccountId', () => { + it('should find an existing account by dbc account id', async () => { kcUsersMock.find.mockResolvedValueOnce([mockedAccount1]); - const ret = await idm.findAccountByTecRefId('any'); + const ret = await idm.findAccountByDbcAccountId('any'); expect(ret).not.toBeNull(); expect(ret).toEqual( @@ -296,18 +296,18 @@ describe('KeycloakIdentityManagementService', () => { }); it('should throw if no account found', async () => { kcUsersMock.find.mockResolvedValueOnce([]); - await expect(idm.findAccountByTecRefId('any')).rejects.toThrow(); + await expect(idm.findAccountByDbcAccountId('any')).rejects.toThrow(); }); it('should throw if multiple accounts found', async () => { kcUsersMock.find.mockResolvedValueOnce([mockedAccount1, mockedAccount2]); - await expect(idm.findAccountByTecRefId('any')).rejects.toThrow(); + await expect(idm.findAccountByDbcAccountId('any')).rejects.toThrow(); }); }); - describe('findAccountByTecRefId', () => { - it('should find an existing account by technical reference id', async () => { + describe('findAccountByDbcUserId', () => { + it('should find an existing account by dbc user id', async () => { kcUsersMock.find.mockResolvedValueOnce([mockedAccount1]); - const ret = await idm.findAccountByFctIntId('any'); + const ret = await idm.findAccountByDbcUserId('any'); expect(ret).not.toBeNull(); expect(ret).toEqual( @@ -322,11 +322,11 @@ describe('KeycloakIdentityManagementService', () => { }); it('should throw if no account found', async () => { kcUsersMock.find.mockResolvedValueOnce([]); - await expect(idm.findAccountByFctIntId('any')).rejects.toThrow(); + await expect(idm.findAccountByDbcUserId('any')).rejects.toThrow(); }); it('should throw if multiple accounts found', async () => { kcUsersMock.find.mockResolvedValueOnce([mockedAccount1, mockedAccount2]); - await expect(idm.findAccountByFctIntId('any')).rejects.toThrow(); + await expect(idm.findAccountByDbcUserId('any')).rejects.toThrow(); }); }); diff --git a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts index 7f6299101b8..f4ea727ffe4 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak/service/keycloak-identity-management.service.ts @@ -1,7 +1,7 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; -import { Counted, IAccount, IAccountUpdate } from '@shared/domain'; +import { Counted, IdmAccount, IdmAccountUpdate } from '@shared/domain'; import { IdentityManagementService, SearchOptions } from '../../identity-management.service'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; @@ -11,7 +11,7 @@ export class KeycloakIdentityManagementService extends IdentityManagementService super(); } - async createAccount(account: IAccount, password?: string): Promise { + async createAccount(account: IdmAccount, password?: string): Promise { const kc = await this.kcAdminClient.callKcAdminClient(); const id = await kc.users.create({ username: account.username, @@ -20,9 +20,9 @@ export class KeycloakIdentityManagementService extends IdentityManagementService lastName: account.lastName, enabled: true, attributes: { - refTechnicalId: account.attRefTechnicalId, - refFunctionalIntId: account.attRefFunctionalIntId, - refFunctionalExtId: account.attRefFunctionalExtId, + dbcAccountId: account.attDbcAccountId, + dbcUserId: account.attDbcUserId, + dbcSystemId: account.attDbcSystemId, }, }); if (id && password) { @@ -43,7 +43,7 @@ export class KeycloakIdentityManagementService extends IdentityManagementService return id.id; } - async updateAccount(id: string, account: IAccountUpdate): Promise { + async updateAccount(id: string, account: IdmAccountUpdate): Promise { await ( await this.kcAdminClient.callKcAdminClient() ).users.update( @@ -73,7 +73,7 @@ export class KeycloakIdentityManagementService extends IdentityManagementService return id; } - async findAccountById(id: string): Promise { + async findAccountById(id: string): Promise { const keycloakUser = await (await this.kcAdminClient.callKcAdminClient()).users.findOne({ id }); if (!keycloakUser) { throw new Error(`Account '${id}' not found`); @@ -81,36 +81,36 @@ export class KeycloakIdentityManagementService extends IdentityManagementService return this.extractAccount(keycloakUser); } - async findAccountByTecRefId(accountTecRefId: string): Promise { + async findAccountByDbcAccountId(accountDbcAccountId: string): Promise { const keycloakUsers = await ( await this.kcAdminClient.callKcAdminClient() - ).users.find({ q: `refTechnicalId:${accountTecRefId} }` }); + ).users.find({ q: `dbcAccountId:${accountDbcAccountId} }` }); if (keycloakUsers.length > 1) { throw new Error('Multiple accounts for the same id!'); } if (keycloakUsers.length === 0) { - throw new Error(`Account '${accountTecRefId}' not found`); + throw new Error(`Account '${accountDbcAccountId}' not found`); } return this.extractAccount(keycloakUsers[0]); } - async findAccountByFctIntId(accountFctIntId: string): Promise { + async findAccountByDbcUserId(accountDbcUserId: string): Promise { const keycloakUsers = await ( await this.kcAdminClient.callKcAdminClient() - ).users.find({ q: `refFunctionalIntId:${accountFctIntId} }` }); + ).users.find({ q: `dbcUserId:${accountDbcUserId} }` }); if (keycloakUsers.length > 1) { throw new Error('Multiple accounts for the same id!'); } if (keycloakUsers.length === 0) { - throw new Error(`Account '${accountFctIntId}' not found`); + throw new Error(`Account '${accountDbcUserId}' not found`); } return this.extractAccount(keycloakUsers[0]); } - async findAccountsByUsername(username: string, options?: SearchOptions): Promise> { + async findAccountsByUsername(username: string, options?: SearchOptions): Promise> { const kc = await this.kcAdminClient.callKcAdminClient(); const total = await kc.users.count({ username }); const results = await kc.users.find({ @@ -123,7 +123,7 @@ export class KeycloakIdentityManagementService extends IdentityManagementService return [accounts, total]; } - async getAllAccounts(): Promise { + async getAllAccounts(): Promise { const keycloakUsers = await (await this.kcAdminClient.callKcAdminClient()).users.find(); return keycloakUsers.map((user: UserRepresentation) => this.extractAccount(user)); } @@ -167,8 +167,8 @@ export class KeycloakIdentityManagementService extends IdentityManagementService await kc.users.update({ id: userId }, user); } - private extractAccount(user: UserRepresentation): IAccount { - const ret: IAccount = { + private extractAccount(user: UserRepresentation): IdmAccount { + const ret: IdmAccount = { id: user.id ?? '', username: user.username, email: user.email, @@ -176,9 +176,9 @@ export class KeycloakIdentityManagementService extends IdentityManagementService lastName: user.lastName, createdDate: user.createdTimestamp ? new Date(user.createdTimestamp) : undefined, }; - ret.attRefFunctionalExtId = this.extractAttributeValue(user.attributes?.refFunctionalExtId); - ret.attRefFunctionalIntId = this.extractAttributeValue(user.attributes?.refFunctionalIntId); - ret.attRefTechnicalId = this.extractAttributeValue(user.attributes?.refTechnicalId); + ret.attDbcSystemId = this.extractAttributeValue(user.attributes?.dbcSystemId); + ret.attDbcUserId = this.extractAttributeValue(user.attributes?.dbcUserId); + ret.attDbcAccountId = this.extractAttributeValue(user.attributes?.dbcAccountId); return ret; } diff --git a/apps/server/src/shared/infra/mail/mail.service.spec.ts b/apps/server/src/shared/infra/mail/mail.service.spec.ts index 81b0eb12db1..58c0ce9336a 100644 --- a/apps/server/src/shared/infra/mail/mail.service.spec.ts +++ b/apps/server/src/shared/infra/mail/mail.service.spec.ts @@ -34,10 +34,12 @@ describe('MailService', () => { expect(service).toBeDefined(); }); - it('should send given data to queue', () => { + 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'); - service.send(data); + + 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 index bae5ebfe738..aaf9cfacb9d 100644 --- a/apps/server/src/shared/infra/mail/mail.service.ts +++ b/apps/server/src/shared/infra/mail/mail.service.ts @@ -15,7 +15,7 @@ export class MailService { @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions ) {} - public send(data: Mail): void { - this.amqpConnection.publish(this.options.exchange, this.options.routingKey, data, { persistent: true }); + 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/infra/oauth-provider/hydra/hydra.adapter.spec.ts b/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts index 6357ba1f7fa..0e244b37d16 100644 --- a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts +++ b/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.spec.ts @@ -1,8 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { HydraAdapter } from '@shared/infra/oauth-provider/hydra/hydra.adapter'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; -import { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, Method } from 'axios'; +import { Test, TestingModule } from '@nestjs/testing'; import { AcceptConsentRequestBody, AcceptLoginRequestBody, @@ -13,9 +12,11 @@ import { ProviderRedirectResponse, RejectRequestBody, } from '@shared/infra/oauth-provider/dto'; -import { of } from 'rxjs'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ProviderConsentSessionResponse } from '@shared/infra/oauth-provider/dto/response/consent-session.response'; +import { HydraAdapter } from '@shared/infra/oauth-provider/hydra/hydra.adapter'; +import { axiosResponseFactory } from '@shared/testing'; +import { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; +import { of } from 'rxjs'; import resetAllMocks = jest.resetAllMocks; class HydraAdapterSpec extends HydraAdapter { @@ -23,21 +24,16 @@ class HydraAdapterSpec extends HydraAdapter { method: Method, url: string, data?: unknown, - additionalHeaders: AxiosRequestHeaders = {} + additionalHeaders?: RawAxiosRequestHeaders ): Promise { return super.request(method, url, data, additionalHeaders); } } -const createAxiosResponse = (data: T): AxiosResponse => { - return { +const createAxiosResponse = (data: T) => + axiosResponseFactory.build({ data, - status: 200, - statusText: '', - headers: {}, - config: {}, - }; -}; + }); describe('HydraService', () => { let module: TestingModule; diff --git a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts b/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts index 0b325640693..f554a15abd3 100644 --- a/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts +++ b/apps/server/src/shared/infra/oauth-provider/hydra/hydra.adapter.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { AxiosRequestHeaders, AxiosResponse, Method } from 'axios'; -import { firstValueFrom, Observable } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; import QueryString from 'qs'; +import { Observable, firstValueFrom } from 'rxjs'; import { URL } from 'url'; import { AcceptConsentRequestBody, @@ -15,8 +15,8 @@ import { ProviderRedirectResponse, RejectRequestBody, } from '../dto'; -import { OauthProviderService } from '../oauth-provider.service'; import { ProviderConsentSessionResponse } from '../dto/response/consent-session.response'; +import { OauthProviderService } from '../oauth-provider.service'; @Injectable() export class HydraAdapter extends OauthProviderService { @@ -158,7 +158,7 @@ export class HydraAdapter extends OauthProviderService { method: Method, url: string, data?: unknown, - additionalHeaders: AxiosRequestHeaders = {} + additionalHeaders: RawAxiosRequestHeaders = {} ): Promise { const observable: Observable> = this.httpService.request({ url, diff --git a/apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts b/apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts index 2a7cef824dc..080bcfebae5 100644 --- a/apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts +++ b/apps/server/src/shared/infra/rabbitmq/exchange/files-storage.ts @@ -13,6 +13,7 @@ export enum ScanStatus { PENDING = 'pending', VERIFIED = 'verified', BLOCKED = 'blocked', + WONT_CHECK = 'wont_check', ERROR = 'error', } diff --git a/apps/server/src/shared/infra/s3-client/README.md b/apps/server/src/shared/infra/s3-client/README.md new file mode 100644 index 00000000000..0170145342b --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/README.md @@ -0,0 +1,41 @@ +# S3 client module + +This module allows to connect to the S3 storage with our abstraction layer. + +## how to use + +You need to create a unique connection token and set it as the connection name in S3 configuration. And you must use this token, when injecting the S3 client into your service. This is **very important**, because multiple modules could potentially use the S3 client with different configurations. + +The S3ClientModule.register method awaits an array of S3 configurations. Also you can create many connections to different S3 providers and buckets. + +```ts +// your.config.ts +export const YOUR_S3_UNIQ_CONNECTION_TOKEN = "YOUR_S3_UNIQ_CONNECTION_TOKEN"; + +const s3Config: S3Config = { + connectionName: YOUR_S3_UNIQ_CONNECTION_TOKEN, // Important! + endpoint: "", + region: "", + bucket: "", + accessKeyId: "", + secretAccessKey: "", +}; + +// your.service.ts + +@Injectable() +export class FilesStorageService { + constructor( + @Inject(YOUR_S3_UNIQ_CONNECTION_TOKEN) // Important! + private readonly storageClient: S3ClientAdapter) +} + +// your.module.ts +@Module({ + imports: [S3ClientModule.register([s3Config]),] + providers: [YourService] +}) + +export class YourModule {} + +``` diff --git a/apps/server/src/shared/infra/s3-client/constants.ts b/apps/server/src/shared/infra/s3-client/constants.ts new file mode 100644 index 00000000000..c1ae920ac28 --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/constants.ts @@ -0,0 +1,2 @@ +export const S3_CONFIG = 'S3_Config'; +export const S3_CLIENT = 'S3_Client'; diff --git a/apps/server/src/shared/infra/s3-client/index.ts b/apps/server/src/shared/infra/s3-client/index.ts new file mode 100644 index 00000000000..a2bfb7428c3 --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/index.ts @@ -0,0 +1,3 @@ +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/shared/infra/s3-client/interface/index.ts new file mode 100644 index 00000000000..19c416406f3 --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/interface/index.ts @@ -0,0 +1,36 @@ +import { Readable } from 'stream'; + +export interface S3Config { + connectionName: string; + endpoint: string; + region: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; +} + +export interface GetFile { + data: Readable; + etag?: string; + contentType?: string; + contentLength?: number; + contentRange?: string; +} + +export interface CopyFiles { + sourcePath: string; + targetPath: string; +} + +export interface File { + data: Readable; + name: string; + mimeType: string; +} + +export interface ListFiles { + path: string; + maxKeys?: number; + nextMarker?: string; + files?: string[]; +} diff --git a/apps/server/src/modules/files-storage/client/s3-client.adapter.spec.ts b/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts similarity index 54% rename from apps/server/src/modules/files-storage/client/s3-client.adapter.spec.ts rename to apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts index bd9a97a1e76..3bcba77d090 100644 --- a/apps/server/src/modules/files-storage/client/s3-client.adapter.spec.ts +++ b/apps/server/src/shared/infra/s3-client/s3-client.adapter.spec.ts @@ -1,26 +1,29 @@ -import { S3Client } from '@aws-sdk/client-s3'; +import { S3Client, S3ServiceException } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { 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 '../dto'; -import { S3Config } from '../interface/config'; +import { FileDto } from '../../../modules/files-storage/dto'; +import { S3_CLIENT, S3_CONFIG } from './constants'; +import { S3Config } from './interface'; import { S3ClientAdapter } from './s3-client.adapter'; const createParameter = () => { + const bucket = 'test-bucket'; const config = { endpoint: '', region: '', - bucket: 'test-bucket', + bucket, accessKeyId: '', secretAccessKey: '', }; const pathToFile = 'test/text.txt'; const bytesRange = 'bytes=0-1'; - return { config, pathToFile, bytesRange }; + return { config, pathToFile, bytesRange, bucket }; }; describe('S3ClientAdapter', () => { @@ -35,11 +38,11 @@ describe('S3ClientAdapter', () => { providers: [ S3ClientAdapter, { - provide: 'S3_Client', + provide: S3_CLIENT, useValue: createMock(), }, { - provide: 'S3_Config', + provide: S3_CONFIG, useValue: createMock(config), }, { @@ -164,11 +167,10 @@ describe('S3ClientAdapter', () => { describe('WHEN client throws error', () => { const setup = (errorKey: string) => { const { pathToFile } = createParameter(); - const error = new Error(errorKey); // @ts-expect-error Testcase - client.send.mockRejectedValueOnce(error); + client.send.mockRejectedValueOnce({ Code: errorKey }); - return { error, pathToFile }; + return { pathToFile }; }; it('should throw NotFoundException', async () => { @@ -267,7 +269,7 @@ describe('S3ClientAdapter', () => { const setup = () => { const { file } = createFile(); const { pathToFile } = createParameter(); - const error = new InternalServerErrorException('testError', 'S3ClientAdapter:create'); + const error = new InternalServerErrorException('S3ClientAdapter:create'); const uploadDoneMock = jest.spyOn(Upload.prototype, 'done').mockRejectedValueOnce(error); @@ -290,31 +292,31 @@ describe('S3ClientAdapter', () => { describe('moveToTrash', () => { const setup = () => { - const { pathToFile } = createParameter(); + const { pathToFile, bucket } = createParameter(); - return { pathToFile }; + return { pathToFile, bucket }; }; it('should call send() of client with copy objects', async () => { - const { pathToFile } = setup(); + const { pathToFile, bucket } = setup(); await service.moveToTrash([pathToFile]); expect(client.send).toBeCalledWith( expect.objectContaining({ - input: { Bucket: 'test-bucket', CopySource: 'test-bucket/test/text.txt', Key: 'trash/test/text.txt' }, + input: { Bucket: bucket, CopySource: `${bucket}/test/text.txt`, Key: 'trash/test/text.txt' }, }) ); }); it('should call send() of client with delete objects', async () => { - const { pathToFile } = setup(); + const { pathToFile, bucket } = setup(); await service.moveToTrash([pathToFile]); expect(client.send).toBeCalledWith( expect.objectContaining({ - input: { Bucket: 'test-bucket', Delete: { Objects: [{ Key: 'test/text.txt' }] } }, + input: { Bucket: bucket, Delete: { Objects: [{ Key: 'test/text.txt' }] } }, }) ); }); @@ -323,7 +325,7 @@ describe('S3ClientAdapter', () => { const { pathToFile } = setup(); // @ts-expect-error should run into error - client.send.mockRejectedValue({ Code: 'NoSuchKey' }); + client.send.mockRejectedValue(new S3ServiceException({ name: 'NoSuchKey' })); const res = await service.moveToTrash([pathToFile]); @@ -338,41 +340,172 @@ describe('S3ClientAdapter', () => { describe('delete', () => { const setup = () => { - const { pathToFile } = createParameter(); + const { pathToFile, bucket } = createParameter(); - return { pathToFile }; + return { pathToFile, bucket }; }; it('should call send() of client with delete objects', async () => { - const { pathToFile } = setup(); + const { pathToFile, bucket } = setup(); await service.delete([pathToFile]); expect(client.send).toBeCalledWith( expect.objectContaining({ - input: { Bucket: 'test-bucket', Delete: { Objects: [{ Key: 'test/text.txt' }] } }, + input: { Bucket: bucket, Delete: { Objects: [{ Key: 'test/text.txt' }] } }, }) ); }); }); + describe('deleteDirectory', () => { + describe('when client receives list objects successfully', () => { + describe('when contents contains key', () => { + const setup = () => { + const { pathToFile, bucket } = createParameter(); + const filePath = 'directory/test.txt'; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockResolvedValueOnce({ Contents: [{ Key: filePath }] }); + + return { pathToFile, bucket, filePath }; + }; + + it('should call send() of client with directory path', async () => { + const { pathToFile, bucket } = setup(); + + await service.deleteDirectory(pathToFile); + + expect(client.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: { Bucket: bucket, Prefix: 'test/text.txt' }, + }) + ); + }); + + it('should call send() with objects to delete', async () => { + const { pathToFile, bucket, filePath } = setup(); + + await service.deleteDirectory(pathToFile); + + expect(client.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: { Bucket: bucket, Delete: { Objects: [{ Key: filePath }] } }, + }) + ); + }); + }); + + describe('when contents is undefined', () => { + const setup = () => { + const { pathToFile } = createParameter(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockResolvedValueOnce({}); + + return { pathToFile }; + }; + + it('should call send() once', async () => { + const { pathToFile } = setup(); + + await service.deleteDirectory(pathToFile); + + expect(client.send).toHaveBeenCalledTimes(1); + }); + }); + + describe('when contents is empty array', () => { + const setup = () => { + const { pathToFile } = createParameter(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockResolvedValueOnce({ Contents: [] }); + + return { pathToFile }; + }; + + it('should not call send() once', async () => { + const { pathToFile } = setup(); + + await service.deleteDirectory(pathToFile); + + expect(client.send).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when client throws error when trying to receive list objects ', () => { + const setup = () => { + const { pathToFile } = createParameter(); + const filePath = 'directory/test.txt'; + const error = new Error('testError'); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockRejectedValueOnce(error); + + const expectedError = new InternalServerErrorException( + 'S3ClientAdapter:deleteDirectory', + ErrorUtils.createHttpExceptionOptions(error) + ); + + return { pathToFile, filePath, expectedError }; + }; + + it('should return InternalServerErrorException', async () => { + const { pathToFile, expectedError } = setup(); + + await expect(service.deleteDirectory(pathToFile)).rejects.toThrowError(expectedError); + }); + }); + + describe('when client throws error when trying to delete files', () => { + const setup = () => { + const { pathToFile } = createParameter(); + const filePath = 'directory/test.txt'; + const error = new Error('testError'); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockResolvedValueOnce({ Contents: [{ Key: filePath }] }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.send.mockRejectedValueOnce(error); + + const expectedError = new InternalServerErrorException( + 'S3ClientAdapter:deleteDirectory', + ErrorUtils.createHttpExceptionOptions(error) + ); + + return { pathToFile, filePath, expectedError }; + }; + + it('should return InternalServerErrorException', async () => { + const { pathToFile, expectedError } = setup(); + + await expect(service.deleteDirectory(pathToFile)).rejects.toThrowError(expectedError); + }); + }); + }); + describe('restore', () => { const setup = () => { - const { pathToFile } = createParameter(); + const { pathToFile, bucket } = createParameter(); - return { pathToFile }; + return { pathToFile, bucket }; }; it('should call send() of client with copy objects', async () => { - const { pathToFile } = setup(); + const { pathToFile, bucket } = setup(); await service.restore([pathToFile]); expect(client.send).toBeCalledWith( expect.objectContaining({ input: { - Bucket: 'test-bucket', - CopySource: 'test-bucket/trash/test/text.txt', + Bucket: bucket, + CopySource: `${bucket}/trash/test/text.txt`, Key: 'test/text.txt', }, }) @@ -380,13 +513,13 @@ describe('S3ClientAdapter', () => { }); it('should call send() of client with delete objects', async () => { - const { pathToFile } = setup(); + const { pathToFile, bucket } = setup(); await service.restore([pathToFile]); expect(client.send).toBeCalledWith( expect.objectContaining({ - input: { Bucket: 'test-bucket', Delete: { Objects: [{ Key: 'trash/test/text.txt' }] } }, + input: { Bucket: bucket, Delete: { Objects: [{ Key: 'trash/test/text.txt' }] } }, }) ); }); @@ -399,6 +532,7 @@ describe('S3ClientAdapter', () => { describe('copy', () => { const setup = () => { + const { bucket } = createParameter(); const pathsToCopy = [ { sourcePath: 'trash/test/text.txt', @@ -406,19 +540,19 @@ describe('S3ClientAdapter', () => { }, ]; - return { pathsToCopy }; + return { pathsToCopy, bucket }; }; it('should call send() of client with copy objects', async () => { - const { pathsToCopy } = setup(); + const { pathsToCopy, bucket } = setup(); await service.copy(pathsToCopy); expect(client.send).toBeCalledWith( expect.objectContaining({ input: { - Bucket: 'test-bucket', - CopySource: 'test-bucket/trash/test/text.txt', + Bucket: bucket, + CopySource: `${bucket}/trash/test/text.txt`, Key: 'test/text.txt', }, }) @@ -467,115 +601,169 @@ describe('S3ClientAdapter', () => { describe('list', () => { const setup = () => { - const prefix = 'test/'; + const path = 'test/'; const keys = Array.from(Array(2500).keys()).map((n) => `KEY-${n}`); const responseContents = keys.map((key) => { return { - Key: `${prefix}${key}`, + Key: `${path}${key}`, }; }); - return { prefix, keys, responseContents }; + return { path, keys, responseContents }; }; afterEach(() => { client.send.mockClear(); }); - it('should truncate result when max is given', async () => { - const { prefix, keys, responseContents } = setup(); + 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), - }); + // @ts-expect-error ignore parameter type of mock function + client.send.mockResolvedValue({ + IsTruncated: false, + Contents: responseContents.slice(0, 500), + }); - const resultKeys = await service.list(prefix, 500); + const resultKeys = await service.list({ path, maxKeys: 500 }); - expect(resultKeys).toEqual(keys.slice(0, 500)); + expect(resultKeys.files).toEqual(keys.slice(0, 500)); - expect(client.send).toBeCalledWith( - expect.objectContaining({ - input: { - Bucket: 'test-bucket', - Prefix: prefix, - ContinuationToken: undefined, - MaxKeys: 500, - }, - }) - ); - }); + expect(client.send).toBeCalledWith( + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: undefined, + MaxKeys: 500, + }, + }) + ); + }); - it('should call send() multiple times if bucket contains more than 1000 keys', async () => { - const { prefix, responseContents, keys } = setup(); + it('should truncate result by S3 limits', async () => { + const { path, keys, responseContents } = setup(); - client.send // @ts-expect-error ignore parameter type of mock function - .mockResolvedValueOnce({ + client.send.mockResolvedValueOnce({ IsTruncated: true, - NextContinuationToken: '1', Contents: responseContents.slice(0, 1000), - }) + ContinuationToken: 'KEY-1000', + }); + // @ts-expect-error ignore parameter type of mock function - .mockResolvedValueOnce({ + client.send.mockResolvedValueOnce({ IsTruncated: true, - NextContinuationToken: '2', - Contents: responseContents.slice(1000, 2000), - }) - // @ts-expect-error ignore parameter type of mock function - .mockResolvedValueOnce({ - Contents: responseContents.slice(2000), + Contents: responseContents.slice(1000, 1200), + ContinuationToken: 'KEY-1200', }); - const resultKeys = await service.list(prefix); + const resultKeys = await service.list({ path, maxKeys: 1200 }); - expect(resultKeys).toEqual(keys); + expect(resultKeys.files).toEqual(keys.slice(0, 1200)); - expect(client.send).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - input: { - Bucket: 'test-bucket', - Prefix: prefix, - ContinuationToken: undefined, - }, - }) - ); + 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: prefix, - ContinuationToken: '1', - }, - }) - ); + expect(client.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: { + Bucket: 'test-bucket', + Prefix: path, + ContinuationToken: 'KEY-1000', + MaxKeys: 200, + }, + }) + ); - expect(client.send).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - input: { - Bucket: 'test-bucket', - Prefix: prefix, + 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', + }, + }) + ); + }); }); - it('should throw error if client rejects with an error', async () => { - const { prefix } = setup(); + 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()); + // @ts-expect-error ignore parameter type of mock function + client.send.mockRejectedValue(new Error()); - const listPromise = service.list(prefix); + const listPromise = service.list({ path }); - await expect(listPromise).rejects.toThrow(); + await expect(listPromise).rejects.toThrow(); + }); }); }); }); diff --git a/apps/server/src/modules/files-storage/client/s3-client.adapter.ts b/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts similarity index 59% rename from apps/server/src/modules/files-storage/client/s3-client.adapter.ts rename to apps/server/src/shared/infra/s3-client/s3-client.adapter.ts index 24a233effbc..1eeb8f1155a 100644 --- a/apps/server/src/modules/files-storage/client/s3-client.adapter.ts +++ b/apps/server/src/shared/infra/s3-client/s3-client.adapter.ts @@ -6,24 +6,24 @@ import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, - ListObjectsV2CommandOutput, S3Client, ServiceOutputTypes, } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { ErrorUtils } from '@src/core/error/utils'; import { LegacyLogger } from '@src/core/logger'; import { Readable } from 'stream'; -import { FileDto } from '../dto'; -import { ICopyFiles, IGetFileResponse, IStorageClient, S3Config } from '../interface'; +import { S3_CLIENT, S3_CONFIG } from './constants'; +import { CopyFiles, File, GetFile, ListFiles, S3Config } from './interface'; @Injectable() -export class S3ClientAdapter implements IStorageClient { +export class S3ClientAdapter { private deletedFolderName = 'trash'; constructor( - @Inject('S3_Client') readonly client: S3Client, - @Inject('S3_Config') readonly config: S3Config, + @Inject(S3_CLIENT) readonly client: S3Client, + @Inject(S3_CONFIG) readonly config: S3Config, private logger: LegacyLogger ) { this.logger.setContext(S3ClientAdapter.name); @@ -40,11 +40,14 @@ export class S3ClientAdapter implements IStorageClient { if (err instanceof Error) { this.logger.error(`${err.message} "${this.config.bucket}"`); } - throw new InternalServerErrorException(err, 'S3ClientAdapter:createBucket'); + throw new InternalServerErrorException( + 'S3ClientAdapter:createBucket', + ErrorUtils.createHttpExceptionOptions(err) + ); } } - public async get(path: string, bytesRange?: string): Promise { + public async get(path: string, bytesRange?: string): Promise { try { this.logger.log({ action: 'get', params: { path, bucket: this.config.bucket } }); @@ -58,6 +61,7 @@ export class S3ClientAdapter implements IStorageClient { const stream = data.Body as Readable; this.checkStreamResponsive(stream, path); + return { data: stream, contentType: data.ContentType, @@ -67,15 +71,16 @@ export class S3ClientAdapter implements IStorageClient { }; } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (err.message && err.message === 'NoSuchKey') { + if (err?.Code === 'NoSuchKey') { this.logger.log(`could not find one of the files for deletion with id ${path}`); throw new NotFoundException('NoSuchKey'); + } else { + throw new InternalServerErrorException('S3ClientAdapter:get', ErrorUtils.createHttpExceptionOptions(err)); } - throw new InternalServerErrorException(err, 'S3ClientAdapter:get'); } } - public async create(path: string, file: FileDto): Promise { + public async create(path: string, file: File): Promise { try { this.logger.log({ action: 'create', params: { path, bucket: this.config.bucket } }); @@ -94,13 +99,13 @@ export class S3ClientAdapter implements IStorageClient { return commandOutput; } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (err.Code && err.Code === 'NoSuchBucket') { + if (err?.Code === 'NoSuchBucket') { await this.createBucket(); return await this.create(path, file); } - throw new InternalServerErrorException(err, 'S3ClientAdapter:create'); + throw new InternalServerErrorException('S3ClientAdapter:create', ErrorUtils.createHttpExceptionOptions(err)); } } @@ -119,11 +124,11 @@ export class S3ClientAdapter implements IStorageClient { return result; } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (err.response && err.response.Code && err.response.Code === 'NoSuchKey') { + if (err?.cause?.name === 'NoSuchKey') { this.logger.log(`could not find one of the files for deletion with ids ${paths.join(',')}`); return []; } - throw new InternalServerErrorException(err, 'S3ClientAdapter:delete'); + throw new InternalServerErrorException('S3ClientAdapter:delete', ErrorUtils.createHttpExceptionOptions(err)); } } @@ -144,11 +149,11 @@ export class S3ClientAdapter implements IStorageClient { return result; } catch (err) { - throw new InternalServerErrorException(err, 'S3ClientAdapter:restore'); + throw new InternalServerErrorException('S3ClientAdapter:restore', ErrorUtils.createHttpExceptionOptions(err)); } } - public async copy(paths: ICopyFiles[]) { + public async copy(paths: CopyFiles[]) { try { this.logger.log({ action: 'copy', params: { paths, bucket: this.config.bucket } }); @@ -168,55 +173,70 @@ export class S3ClientAdapter implements IStorageClient { return result; } catch (err) { - throw new InternalServerErrorException(err, 'S3ClientAdapter:copy'); + throw new InternalServerErrorException('S3ClientAdapter:copy', ErrorUtils.createHttpExceptionOptions(err)); } } public async delete(paths: string[]) { - this.logger.log({ action: 'delete', params: { paths, bucket: this.config.bucket } }); + try { + this.logger.log({ action: 'delete', params: { paths, bucket: this.config.bucket } }); - const pathObjects = paths.map((p) => { - return { Key: p }; - }); - const req = new DeleteObjectsCommand({ - Bucket: this.config.bucket, - Delete: { Objects: pathObjects }, - }); + const pathObjects = paths.map((p) => { + return { Key: p }; + }); + const req = new DeleteObjectsCommand({ + Bucket: this.config.bucket, + Delete: { Objects: pathObjects }, + }); - return this.client.send(req); - } + const result = await this.client.send(req); - public async list(prefix: string, maxKeys?: number) { - this.logger.log({ action: 'list', params: { prefix, bucket: this.config.bucket } }); + return result; + } catch (err) { + throw new InternalServerErrorException('S3ClientAdapter:delete', ErrorUtils.createHttpExceptionOptions(err)); + } + } + public async list(params: ListFiles) { try { - let files: string[] = []; - let ret: ListObjectsV2CommandOutput | undefined; + this.logger.log({ action: 'list', params }); - do { - const req = new ListObjectsV2Command({ - Bucket: this.config.bucket, - Prefix: prefix, - ContinuationToken: ret?.NextContinuationToken, - MaxKeys: maxKeys && maxKeys - files.length, - }); + const result = await this.listObjectKeysRecursive(params); + + return result; + } catch (err) { + throw new InternalServerErrorException('S3ClientAdapter:listDirectory'); + } + } - // Iterations are dependent on each other - // eslint-disable-next-line no-await-in-loop - ret = await this.client.send(req); + private async listObjectKeysRecursive(params: ListFiles) { + const { path, maxKeys, nextMarker } = params; + let files: string[] = params.files ? params.files : []; + const MaxKeys = maxKeys && maxKeys - files.length; - const returnedFiles = - ret?.Contents?.filter((o) => o.Key) - .map((o) => o.Key as string) // Can not be undefined because of filter above - .map((key) => key.substring(prefix.length)) ?? []; + const req = new ListObjectsV2Command({ + Bucket: this.config.bucket, + Prefix: path, + ContinuationToken: nextMarker, + MaxKeys, + }); - files = files.concat(returnedFiles); - } while (ret?.IsTruncated && (!maxKeys || files.length < maxKeys)); + const data = await this.client.send(req); - return files; - } catch (err) { - throw new InternalServerErrorException(err, 'S3ClientAdapter:list'); + 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 = { 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) { @@ -241,6 +261,32 @@ export class S3ClientAdapter implements IStorageClient { } } + public async deleteDirectory(path: string) { + try { + this.logger.log({ action: 'deleteDirectory', params: { path, bucket: this.config.bucket } }); + + const req = new ListObjectsV2Command({ + Bucket: this.config.bucket, + Prefix: path, + }); + + const data = await this.client.send(req); + + if (data.Contents?.length && data.Contents?.length > 0) { + const pathObjects = data.Contents.map((p) => p.Key); + + const filteredPathObjects = pathObjects.filter((p): p is string => !!p); + + await this.delete(filteredPathObjects); + } + } catch (err) { + throw new InternalServerErrorException( + 'S3ClientAdapter:deleteDirectory', + ErrorUtils.createHttpExceptionOptions(err) + ); + } + } + /* istanbul ignore next */ private checkStreamResponsive(stream: Readable, context: string) { let timer: NodeJS.Timeout; diff --git a/apps/server/src/shared/infra/s3-client/s3-client.module.spec.ts b/apps/server/src/shared/infra/s3-client/s3-client.module.spec.ts new file mode 100644 index 00000000000..39ebb4f7478 --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/s3-client.module.spec.ts @@ -0,0 +1,90 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Inject } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { S3ClientAdapter } from './s3-client.adapter'; +import { S3ClientModule } from './s3-client.module'; + +const connectionOne = 'connectionOne'; +const connectionTwo = 'connectionTwo'; + +class OneService { + constructor(@Inject(connectionOne) public s3client: S3ClientAdapter) {} +} + +describe('S3ClientModule', () => { + let module: TestingModule; + const s3ClientConfigOne = { + connectionName: connectionOne, + endpoint: 'endpoint-1', + region: 'region-eu-2', + bucket: 'bucket-1', + accessKeyId: 'accessKeyId-1', + secretAccessKey: 'secretAccessKey-1', + }; + const s3ClientConfigTwo = { + connectionName: connectionTwo, + endpoint: 'endpoint-2', + region: 'region-eu-2', + bucket: 'bucket-2', + accessKeyId: 'accessKeyId-2', + secretAccessKey: 'secretAccessKey-2', + }; + + let s3ClientAdapterOne: S3ClientAdapter; + let s3ClientAdapterTwo: S3ClientAdapter; + let serviceOne: OneService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + S3ClientModule.register([s3ClientConfigOne, s3ClientConfigTwo]), + ConfigModule.forRoot({ ignoreEnvFile: true, ignoreEnvVars: true, isGlobal: true }), + ], + providers: [ + { + provide: LegacyLogger, + useValue: createMock(), + }, + OneService, + ], + }).compile(); + + s3ClientAdapterOne = module.get(connectionOne); + s3ClientAdapterTwo = module.get(connectionTwo); + serviceOne = module.get(OneService); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when connectionOne is initialized with register method', () => { + it('should be defined', () => { + expect(s3ClientAdapterOne).toBeDefined(); + }); + + it('should has correctly connection', () => { + expect(s3ClientAdapterOne.config).toBe(s3ClientConfigOne); + }); + }); + + describe('when connectionTwo is initialized with register method', () => { + it('should be defined', () => { + expect(s3ClientAdapterTwo).toBeDefined(); + }); + + it('should has correctly connection', () => { + expect(s3ClientAdapterTwo.config).toBe(s3ClientConfigTwo); + }); + }); + + describe('OneService', () => { + describe('when connectionOne is injected', () => { + it('should has injected s3ClientAdapterOne', () => { + expect(serviceOne.s3client).toBe(s3ClientAdapterOne); + }); + }); + }); +}); diff --git a/apps/server/src/shared/infra/s3-client/s3-client.module.ts b/apps/server/src/shared/infra/s3-client/s3-client.module.ts new file mode 100644 index 00000000000..d4a366a9771 --- /dev/null +++ b/apps/server/src/shared/infra/s3-client/s3-client.module.ts @@ -0,0 +1,41 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { DynamicModule, Module } from '@nestjs/common'; +import { LegacyLogger, LoggerModule } from '@src/core/logger'; +import { S3Config } from './interface'; +import { S3ClientAdapter } from './s3-client.adapter'; + +const createS3ClientAdapter = (config: S3Config, legacyLogger: LegacyLogger) => { + const { region, accessKeyId, secretAccessKey, endpoint } = config; + + const s3Client = new S3Client({ + region, + credentials: { + accessKeyId, + secretAccessKey, + }, + endpoint, + forcePathStyle: true, + tls: true, + }); + return new S3ClientAdapter(s3Client, config, legacyLogger); +}; + +@Module({}) +export class S3ClientModule { + static register(configs: S3Config[]): DynamicModule { + const providers = configs.flatMap((config) => [ + { + provide: config.connectionName, + useFactory: (logger: LegacyLogger) => createS3ClientAdapter(config, logger), + inject: [LegacyLogger], + }, + ]); + + return { + module: S3ClientModule, + imports: [LoggerModule], + providers, + exports: providers, + }; + } +} diff --git a/apps/server/src/shared/repo/card/card-element.repo.spec.ts b/apps/server/src/shared/repo/card/card-element.repo.spec.ts deleted file mode 100644 index 80fe29639ce..00000000000 --- a/apps/server/src/shared/repo/card/card-element.repo.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { CardElement, RichTextCardElement } from '@shared/domain'; -import { cleanupCollections } from '@shared/testing'; - -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; - -import { CardElementRepo, RichTextCardElementRepo } from './card-element.repo'; - -describe('CardElementRepo', () => { - let module: TestingModule; - let repo: CardElementRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [CardElementRepo], - }).compile(); - repo = module.get(CardElementRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - await em.nativeDelete(CardElement, {}); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(CardElement); - }); -}); - -describe('RichTextCardElementRepo', () => { - let module: TestingModule; - let repo: RichTextCardElementRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [RichTextCardElementRepo], - }).compile(); - repo = module.get(RichTextCardElementRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - await em.nativeDelete(RichTextCardElement, {}); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(RichTextCardElement); - }); -}); diff --git a/apps/server/src/shared/repo/card/card-element.repo.ts b/apps/server/src/shared/repo/card/card-element.repo.ts deleted file mode 100644 index 2b76d98a89e..00000000000 --- a/apps/server/src/shared/repo/card/card-element.repo.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CardElement, RichTextCardElement } from '@shared/domain'; -import { BaseRepo } from '../base.repo'; - -@Injectable() -export class CardElementRepo extends BaseRepo { - get entityName() { - return CardElement; - } -} - -@Injectable() -export class RichTextCardElementRepo extends BaseRepo { - get entityName() { - return RichTextCardElement; - } -} diff --git a/apps/server/src/shared/repo/card/index.ts b/apps/server/src/shared/repo/card/index.ts deleted file mode 100644 index 5d3fac4fd09..00000000000 --- a/apps/server/src/shared/repo/card/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './task-card.repo'; -export * from './card-element.repo'; diff --git a/apps/server/src/shared/repo/card/task-card.repo.spec.ts b/apps/server/src/shared/repo/card/task-card.repo.spec.ts deleted file mode 100644 index cf7c2883fbb..00000000000 --- a/apps/server/src/shared/repo/card/task-card.repo.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TaskCard } from '@shared/domain'; -import { - cleanupCollections, - courseFactory, - richTextCardElementFactory, - taskCardFactory, - taskFactory, -} from '@shared/testing'; - -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; - -import { TaskCardRepo } from './task-card.repo'; - -describe('TaskCardRepo', () => { - let module: TestingModule; - let repo: TaskCardRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [TaskCardRepo], - }).compile(); - repo = module.get(TaskCardRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - await em.nativeDelete(TaskCard, {}); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(TaskCard); - }); - - describe('findById', () => { - it('should load task card with content', async () => { - const richTextCardElement = richTextCardElementFactory.build(); - const taskCard = taskCardFactory.build({ cardElements: [richTextCardElement] }); - await em.persistAndFlush(taskCard); - - em.clear(); - - const result = await repo.findById(taskCard.id); - expect(result.id).toEqual(taskCard.id); - }); - - it('should populate all elements correctly', async () => { - const course = courseFactory.build(); - const task = taskFactory.build({ course }); - const richTextCardElement = richTextCardElementFactory.build(); - const taskCard = taskCardFactory.build({ task, cardElements: [richTextCardElement] }); - await repo.save(taskCard); - - em.clear(); - - const result = await repo.findById(taskCard.id); - expect(result.cardElements).toBeDefined(); - expect(result.task).toBeDefined(); - expect(result.task.course).toBeDefined(); - }); - }); -}); diff --git a/apps/server/src/shared/repo/card/task-card.repo.ts b/apps/server/src/shared/repo/card/task-card.repo.ts deleted file mode 100644 index 5ab4d733a02..00000000000 --- a/apps/server/src/shared/repo/card/task-card.repo.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId, TaskCard } from '@shared/domain'; -import { BaseRepo } from '../base.repo'; - -@Injectable() -export class TaskCardRepo extends BaseRepo { - get entityName() { - return TaskCard; - } - - private async populate(taskCards: TaskCard[]): Promise { - await this._em.populate(taskCards, ['cardElements', 'task', 'task.course']); - } - - async findById(id: EntityId): Promise { - const card = await this._em.findOneOrFail(this.entityName, { id }); - - await this.populate([card]); - - return card; - } -} 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 e7f7609ba14..ddff5660b2b 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 @@ -1,26 +1,23 @@ import { createMock } from '@golevelup/ts-jest'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { - ContextExternalTool, - ContextExternalToolDO, - ContextExternalToolType, - CustomParameterEntryDO, - School, - SchoolExternalTool, -} from '@shared/domain'; +import { School } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; import { cleanupCollections, - contextExternalToolDOFactory, + contextExternalToolEntityFactory, contextExternalToolFactory, - schoolExternalToolFactory, + schoolExternalToolEntityFactory, schoolFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { CustomParameterEntry } from '@src/modules/tool/common/domain'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextExternalToolProps } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolEntity, ContextExternalToolType } from '@src/modules/tool/context-external-tool/entity'; import { ContextExternalToolQuery } from '@src/modules/tool/context-external-tool/uc/dto/context-external-tool.types'; -import { ToolContextType } from '@src/modules/tool/common/interface'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; import { ContextExternalToolRepo } from './context-external-tool.repo'; describe('ContextExternalToolRepo', () => { @@ -55,15 +52,15 @@ describe('ContextExternalToolRepo', () => { const createExternalTools = () => { const school: School = schoolFactory.buildWithId(); - const schoolExternalTool1: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ school }); - const schoolExternalTool2: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ school }); - const contextExternalTool1: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school }); + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school }); + const contextExternalTool1: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalTool1, }); - const contextExternalTool2: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const contextExternalTool2: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalTool2, }); - const contextExternalTool3: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const contextExternalTool3: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalTool1, }); @@ -79,7 +76,7 @@ describe('ContextExternalToolRepo', () => { describe('getEntityName', () => { it('should return ContextExternalTool', () => { const { entityName } = repo; - expect(entityName).toEqual(ContextExternalTool); + expect(entityName).toEqual(ContextExternalToolEntity); }); }); @@ -132,13 +129,13 @@ describe('ContextExternalToolRepo', () => { describe('save', () => { describe('when context is known', () => { function setup() { - const domainObject: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const domainObject: ContextExternalTool = contextExternalToolFactory.build({ displayName: 'displayName', contextRef: { id: new ObjectId().toHexString(), type: ToolContextType.COURSE, }, - parameters: [new CustomParameterEntryDO({ name: 'param', value: 'value' })], + parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], schoolToolRef: { schoolToolId: new ObjectId().toHexString(), schoolId: undefined, @@ -155,7 +152,7 @@ describe('ContextExternalToolRepo', () => { const { domainObject } = setup(); const { id, ...expected } = domainObject; - const result: ContextExternalToolDO = await repo.save(domainObject); + const result: ContextExternalTool = await repo.save(domainObject); expect(result).toMatchObject(expected); expect(result.id).toBeDefined(); @@ -164,13 +161,13 @@ describe('ContextExternalToolRepo', () => { describe('when context is unknown', () => { const setup = () => { - const domainObject: ContextExternalToolDO = contextExternalToolDOFactory.build({ + const domainObject: ContextExternalTool = contextExternalToolFactory.build({ contextRef: { id: new ObjectId().toHexString(), type: 'UNKNOWN' as ToolContextType, }, displayName: 'displayName', - parameters: [new CustomParameterEntryDO({ name: 'param', value: 'value' })], + parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], schoolToolRef: { schoolToolId: new ObjectId().toHexString(), }, @@ -209,7 +206,7 @@ describe('ContextExternalToolRepo', () => { schoolToolRef: { schoolToolId: schoolExternalTool1.id }, }; - const result: ContextExternalToolDO[] = await repo.find(query); + const result: ContextExternalTool[] = await repo.find(query); expect(result[0].schoolToolRef.schoolToolId).toEqual(schoolExternalTool1.id); }); @@ -235,7 +232,7 @@ describe('ContextExternalToolRepo', () => { }, }; - const result: ContextExternalToolDO[] = await repo.find(query); + const result: ContextExternalTool[] = await repo.find(query); expect(result[0].contextRef.id).toEqual(contextExternalTool1.contextId); }); @@ -261,7 +258,7 @@ describe('ContextExternalToolRepo', () => { it('should return correct results', async () => { const { query } = await setup(); - const result: ContextExternalToolDO[] = await repo.find(query); + const result: ContextExternalTool[] = await repo.find(query); expect(result[0].contextRef.type).toEqual(ToolContextType.COURSE); }); @@ -314,10 +311,55 @@ describe('ContextExternalToolRepo', () => { it('should return empty array', async () => { const { query } = await setup(); - const result: ContextExternalToolDO[] = await repo.find(query); + const result: ContextExternalTool[] = await repo.find(query); expect(result).toEqual([]); }); }); }); + + describe('findById', () => { + describe('when a ContextExternalTool is found', () => { + const setup = async () => { + const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId(); + const contextExternalTool = contextExternalToolEntityFactory.buildWithId({ + contextType: ContextExternalToolType.COURSE, + schoolTool: schoolExternalTool, + }); + + await em.persistAndFlush([schoolExternalTool, contextExternalTool]); + + return { + contextExternalTool, + schoolExternalTool, + }; + }; + + it('should return correct results', async () => { + const { contextExternalTool, schoolExternalTool } = await setup(); + + const result = await repo.findById(contextExternalTool.id); + + expect(result).toEqual({ + id: contextExternalTool.id, + contextRef: { + id: contextExternalTool.contextId, + type: ToolContextType.COURSE, + }, + displayName: contextExternalTool.displayName, + parameters: [ + { + name: contextExternalTool.parameters[0].name, + value: contextExternalTool.parameters[0].value, + }, + ], + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.school.id, + }, + toolVersion: contextExternalTool.toolVersion, + }); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index 6f55517b65c..b766828beff 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -1,38 +1,38 @@ import { EntityName } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { - ContextExternalTool, - ContextExternalToolDO, - ContextRef, - IContextExternalToolProperties, - SchoolExternalTool, - SchoolExternalToolRefDO, -} from '@shared/domain'; -import { ContextExternalToolType } from '@shared/domain/entity/tools/course-external-tool/context-external-tool-type.enum'; import { BaseDORepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; -import { ToolContextType } from '@src/modules/tool/common/interface/tool-context-type.enum'; +import { ToolContextType } from '@src/modules/tool/common/enum/tool-context-type.enum'; +import { ContextExternalTool, ContextRef } from '@src/modules/tool/context-external-tool/domain'; +import { + ContextExternalToolEntity, + ContextExternalToolType, + IContextExternalToolProperties, +} from '@src/modules/tool/context-external-tool/entity'; import { ContextExternalToolQuery } from '@src/modules/tool/context-external-tool/uc/dto/context-external-tool.types'; +import { SchoolExternalToolRefDO } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { EntityId } from '../../domain'; import { ExternalToolRepoMapper } from '../externaltool'; import { ContextExternalToolScope } from './context-external-tool.scope'; @Injectable() export class ContextExternalToolRepo extends BaseDORepo< - ContextExternalToolDO, ContextExternalTool, + ContextExternalToolEntity, IContextExternalToolProperties > { constructor(protected readonly _em: EntityManager, protected readonly logger: LegacyLogger) { super(_em, logger); } - get entityName(): EntityName { - return ContextExternalTool; + get entityName(): EntityName { + return ContextExternalToolEntity; } - entityFactory(props: IContextExternalToolProperties): ContextExternalTool { - return new ContextExternalTool(props); + entityFactory(props: IContextExternalToolProperties): ContextExternalToolEntity { + return new ContextExternalToolEntity(props); } async deleteBySchoolExternalToolIds(schoolExternalToolIds: string[]): Promise { @@ -42,17 +42,31 @@ export class ContextExternalToolRepo extends BaseDORepo< return count; } - async find(query: ContextExternalToolQuery): Promise { + async find(query: ContextExternalToolQuery): Promise { const scope: ContextExternalToolScope = this.buildScope(query); - const entities: ContextExternalTool[] = await this._em.find(this.entityName, scope.query, { + const entities: ContextExternalToolEntity[] = await this._em.find(this.entityName, scope.query, { populate: ['schoolTool.school'], }); - const dos: ContextExternalToolDO[] = entities.map((entity: ContextExternalTool) => this.mapEntityToDO(entity)); + const dos: ContextExternalTool[] = entities.map((entity: ContextExternalToolEntity) => this.mapEntityToDO(entity)); return dos; } + public override async findById(id: EntityId): Promise { + const entity: ContextExternalToolEntity = await this._em.findOneOrFail( + this.entityName, + { id }, + { + populate: ['schoolTool.school'], + } + ); + + const mapped: ContextExternalTool = this.mapEntityToDO(entity); + + return mapped; + } + private buildScope(query: ContextExternalToolQuery): ContextExternalToolScope { const scope: ContextExternalToolScope = new ContextExternalToolScope(); @@ -65,7 +79,7 @@ export class ContextExternalToolRepo extends BaseDORepo< return scope; } - mapEntityToDO(entity: ContextExternalTool): ContextExternalToolDO { + mapEntityToDO(entity: ContextExternalToolEntity): ContextExternalTool { const schoolToolRef: SchoolExternalToolRefDO = new SchoolExternalToolRefDO({ schoolId: entity.schoolTool.school?.id, schoolToolId: entity.schoolTool.id, @@ -76,7 +90,7 @@ export class ContextExternalToolRepo extends BaseDORepo< type: this.mapContextTypeToDoType(entity.contextType), }); - return new ContextExternalToolDO({ + return new ContextExternalTool({ id: entity.id, schoolToolRef, contextRef, @@ -86,12 +100,12 @@ export class ContextExternalToolRepo extends BaseDORepo< }); } - mapDOToEntityProperties(entityDO: ContextExternalToolDO): IContextExternalToolProperties { + mapDOToEntityProperties(entityDO: ContextExternalTool): IContextExternalToolProperties { return { contextId: entityDO.contextRef.id, contextType: this.mapContextTypeToEntityType(entityDO.contextRef.type), displayName: entityDO.displayName, - schoolTool: this._em.getReference(SchoolExternalTool, entityDO.schoolToolRef.schoolToolId), + schoolTool: this._em.getReference(SchoolExternalToolEntity, entityDO.schoolToolRef.schoolToolId), toolVersion: entityDO.toolVersion, parameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(entityDO.parameters), }; diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts index 1812cfdb90c..b6896ae497f 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts @@ -1,7 +1,7 @@ -import { SchoolExternalTool } from '@shared/domain'; -import { schoolExternalToolFactory } from '@shared/testing'; +import { schoolExternalToolEntityFactory } from '@shared/testing'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; import { ContextExternalToolScope } from './context-external-tool.scope'; -import { ToolContextType } from '../../../modules/tool/common/interface'; describe('CourseExternalToolScope', () => { let scope: ContextExternalToolScope; @@ -21,11 +21,11 @@ describe('CourseExternalToolScope', () => { describe('when id parameter is defined', () => { it('should return scope with added id to query', () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); - scope.byId(schoolExternalTool.id); + scope.byId(schoolExternalToolEntity.id); - expect(scope.query).toEqual({ id: schoolExternalTool.id }); + expect(scope.query).toEqual({ id: schoolExternalToolEntity.id }); }); }); }); @@ -40,11 +40,11 @@ describe('CourseExternalToolScope', () => { describe('when schoolToolId parameter is defined', () => { it('should return scope with added schoolToolId to query', () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); - scope.bySchoolToolId(schoolExternalTool.id); + scope.bySchoolToolId(schoolExternalToolEntity.id); - expect(scope.query).toEqual({ schoolTool: schoolExternalTool.id }); + expect(scope.query).toEqual({ schoolTool: schoolExternalToolEntity.id }); }); }); }); @@ -59,11 +59,11 @@ describe('CourseExternalToolScope', () => { describe('when contextId parameter is defined', () => { it('should return scope with added contextId to query', () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); - scope.byContextId(schoolExternalTool.id); + scope.byContextId(schoolExternalToolEntity.id); - expect(scope.query).toEqual({ contextId: schoolExternalTool.id }); + expect(scope.query).toEqual({ contextId: schoolExternalToolEntity.id }); }); }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.ts index daf859ec1b0..51540ed17ba 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.ts @@ -1,8 +1,9 @@ import { Scope } from '@shared/repo'; -import { ContextExternalTool, EntityId } from '@shared/domain'; -import { ToolContextType } from '../../../modules/tool/common/interface'; +import { EntityId } from '@shared/domain'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; -export class ContextExternalToolScope extends Scope { +export class ContextExternalToolScope extends Scope { byId(id: EntityId | undefined): ContextExternalToolScope { if (id !== undefined) { this.addQuery({ id }); 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 b2cf3f1f4ae..648bc4256e9 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 @@ -368,55 +368,6 @@ describe('course repo', () => { }); }); - describe('findOneForTeacherOrSubstituteTeacher', () => { - const setup = (countUser = 1) => { - const user = userFactory.buildListWithId(countUser); - return { user }; - }; - it('should find course of teacher and substitution teacher', async () => { - const { user } = setup(4); - const [teacher, substitutionTeacher, ...students] = user; - - const course = courseFactory.build({ - teachers: [teacher], - substitutionTeachers: [substitutionTeacher], - students, - }); - await em.persistAndFlush([course]); - em.clear(); - - const result = await repo.findOneForTeacherOrSubstituteTeacher(teacher.id, course.id); - expect(result.id).toEqual(course.id); - expect(result.teachers[0].id).toEqual(teacher.id); - expect(result.substitutionTeachers[0].id).toEqual(substitutionTeacher.id); - expect(result.students.length).toEqual(2); - }); - it('should throw error if course is not found', async () => { - const { user } = setup(); - const [teacher] = user; - const unknownId = new ObjectId().toHexString(); - - await expect(async () => { - await repo.findOneForTeacherOrSubstituteTeacher(teacher.id, unknownId); - }).rejects.toThrow(); - }); - it('should throw error if user is not teacher or substitution teacher', async () => { - const { user } = setup(2); - const [teacher, substitutionTeacher] = user; - const course = courseFactory.build({ - teachers: [teacher], - substitutionTeachers: [substitutionTeacher], - }); - const unknownId = new ObjectId().toHexString(); - await em.persistAndFlush([course]); - em.clear(); - - await expect(async () => { - await repo.findOneForTeacherOrSubstituteTeacher(unknownId, course.id); - }).rejects.toThrow(); - }); - }); - describe('findById', () => { it('should find a course by its id', async () => { const course = courseFactory.build({ name: 'important course' }); diff --git a/apps/server/src/shared/repo/course/course.repo.ts b/apps/server/src/shared/repo/course/course.repo.ts index 49cfed8241d..3f973dcc7d3 100644 --- a/apps/server/src/shared/repo/course/course.repo.ts +++ b/apps/server/src/shared/repo/course/course.repo.ts @@ -135,14 +135,4 @@ export class CourseRepo extends BaseRepo { return course; } - - async findOneForTeacherOrSubstituteTeacher(userId: EntityId, courseId: EntityId): Promise { - const scope = new CourseScope(); - scope.forCourseId(courseId); - scope.forTeacherOrSubstituteTeacher(userId); - const course = await this._em.findOneOrFail(Course, scope.query); - - await this._em.populate(course, ['students']); - return course; - } } diff --git a/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.spec.ts index 0e05f7d1907..d40c8654878 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.spec.ts @@ -1,15 +1,17 @@ import { QueryOrderMap } from '@mikro-orm/core'; -import { ExternalTool, ExternalToolDO, LtiTool, SortOrder, SortOrderMap } from '@shared/domain'; +import { LtiTool, SortOrder, SortOrderMap } from '@shared/domain'; import { ExternalToolSortingMapper } from '@shared/repo'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; describe('ExternalToolSortingMapper', () => { describe('mapDOSortOrderToQueryOrder', () => { it('should map sortOrderMap of DO to queryOrderMap of entity', () => { - const doSortOrderMap: SortOrderMap = { + const doSortOrderMap: SortOrderMap = { id: SortOrder.asc, name: SortOrder.asc, }; - const expectedResponse: QueryOrderMap = { + const expectedResponse: QueryOrderMap = { _id: doSortOrderMap.id, name: doSortOrderMap.name, }; @@ -21,15 +23,15 @@ describe('ExternalToolSortingMapper', () => { }); it('should return queryOrderMap without undefined fields', () => { - const doSortOrderMap: SortOrderMap = { + const doSortOrderMap: SortOrderMap = { id: SortOrder.asc, name: undefined, }; - const expectedResponse: QueryOrderMap = { + const expectedResponse: QueryOrderMap = { _id: doSortOrderMap.id, }; - const entityQueryOrderMap: QueryOrderMap = + const entityQueryOrderMap: QueryOrderMap = ExternalToolSortingMapper.mapDOSortOrderToQueryOrder(doSortOrderMap); expect(entityQueryOrderMap).toEqual(expectedResponse); diff --git a/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.ts index 887669365bf..751130ad784 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool-sorting.mapper.ts @@ -1,9 +1,11 @@ import { QueryOrderMap } from '@mikro-orm/core'; -import { ExternalTool, ExternalToolDO, SortOrderMap } from '@shared/domain'; +import { SortOrderMap } from '@shared/domain'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; export class ExternalToolSortingMapper { - static mapDOSortOrderToQueryOrder(sort: SortOrderMap): QueryOrderMap { - const queryOrderMap: QueryOrderMap = { + static mapDOSortOrderToQueryOrder(sort: SortOrderMap): QueryOrderMap { + const queryOrderMap: QueryOrderMap = { _id: sort.id, name: sort.name, }; 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 476cfb6d870..f61ae282350 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 @@ -1,28 +1,28 @@ 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 { ExternalToolRepo, ExternalToolRepoMapper } from '@shared/repo'; +import { cleanupCollections, externalToolEntityFactory } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { ExternalToolSearchQuery } from '@src/modules/tool'; +import { CustomParameter } from '@src/modules/tool/common/domain'; import { - BasicToolConfigDO, - CustomParameterDO, CustomParameterLocation, CustomParameterScope, CustomParameterType, - ExternalTool, - ExternalToolDO, - IFindOptions, - Lti11ToolConfigDO, LtiMessageType, LtiPrivacyPermission, - Oauth2ToolConfigDO, - Page, - SortOrder, ToolConfigType, -} from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { ExternalToolRepo, ExternalToolRepoMapper } from '@shared/repo'; -import { cleanupCollections, externalToolFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { ExternalToolSearchQuery } from '@src/modules/tool'; +} from '@src/modules/tool/common/enum'; +import { + BasicToolConfig, + ExternalTool, + Lti11ToolConfig, + Oauth2ToolConfig, +} from '@src/modules/tool/external-tool/domain'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; describe('ExternalToolRepo', () => { let module: TestingModule; @@ -58,18 +58,18 @@ describe('ExternalToolRepo', () => { const client1Id = 'client-1'; const client2Id = 'client-2'; - const externalTool: ExternalTool = externalToolFactory.withBasicConfig().buildWithId(); - const externalOauthTool: ExternalTool = externalToolFactory.withOauth2Config('client-1').buildWithId(); - const externalOauthTool2: ExternalTool = externalToolFactory.withOauth2Config('client-2').buildWithId(); - const externalLti11Tool: ExternalTool = externalToolFactory.withLti11Config().buildWithId(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.withBasicConfig().buildWithId(); + const externalOauthTool: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-1').buildWithId(); + const externalOauthTool2: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-2').buildWithId(); + const externalLti11Tool: ExternalToolEntity = externalToolEntityFactory.withLti11Config().buildWithId(); - await em.persistAndFlush([externalTool, externalOauthTool, externalOauthTool2, externalLti11Tool]); + await em.persistAndFlush([externalToolEntity, externalOauthTool, externalOauthTool2, externalLti11Tool]); em.clear(); const queryExternalToolDO: ExternalToolSearchQuery = { name: 'external-tool-*' }; return { - externalTool, + externalToolEntity, externalOauthTool, externalOauthTool2, externalLti11Tool, @@ -81,22 +81,22 @@ describe('ExternalToolRepo', () => { it('getEntityName should return ExternalTool', () => { const { entityName } = repo; - expect(entityName).toEqual(ExternalTool); + expect(entityName).toEqual(ExternalToolEntity); }); describe('findByName', () => { it('should find an external tool with given toolName', async () => { - const { externalTool } = await setup(); + const { externalToolEntity } = await setup(); - const result: ExternalToolDO | null = await repo.findByName(externalTool.name); + const result: ExternalTool | null = await repo.findByName(externalToolEntity.name); - expect(result?.name).toEqual(externalTool.name); + expect(result?.name).toEqual(externalToolEntity.name); }); it('should return null when no external tool with the given name was found', async () => { await setup(); - const result: ExternalToolDO | null = await repo.findByName('notExisting'); + const result: ExternalTool | null = await repo.findByName('notExisting'); expect(result).toBeNull(); }); @@ -106,13 +106,13 @@ describe('ExternalToolRepo', () => { it('should find all external tools with given toolConfigType', async () => { await setup(); - const result: ExternalToolDO[] = await repo.findAllByConfigType(ToolConfigType.OAUTH2); + const result: ExternalTool[] = await repo.findAllByConfigType(ToolConfigType.OAUTH2); expect(result.length).toEqual(2); }); it('should return an empty array when no externalTools were found', async () => { - const result: ExternalToolDO[] = await repo.findAllByConfigType(ToolConfigType.LTI11); + const result: ExternalTool[] = await repo.findAllByConfigType(ToolConfigType.LTI11); expect(result.length).toEqual(0); }); @@ -122,29 +122,29 @@ describe('ExternalToolRepo', () => { it('should find external tool with given client id', async () => { const { client1Id } = await setup(); - const result: ExternalToolDO | null = await repo.findByOAuth2ConfigClientId(client1Id); + const result: ExternalTool | null = await repo.findByOAuth2ConfigClientId(client1Id); - expect((result?.config as Oauth2ToolConfigDO).clientId).toEqual(client1Id); + expect((result?.config as Oauth2ToolConfig).clientId).toEqual(client1Id); }); it('should return an empty array when no externalTools were found', async () => { await setup(); - const result: ExternalToolDO | null = await repo.findByOAuth2ConfigClientId('unknown-client'); + const result: ExternalTool | null = await repo.findByOAuth2ConfigClientId('unknown-client'); expect(result).toBeNull(); }); }); describe('save', () => { - const setupDO = (config: BasicToolConfigDO | Lti11ToolConfigDO | Oauth2ToolConfigDO) => { - const domainObject: ExternalToolDO = new ExternalToolDO({ + const setupDO = (config: BasicToolConfig | Lti11ToolConfig | Oauth2ToolConfig) => { + const domainObject: ExternalTool = new ExternalTool({ name: 'name', url: 'url', logoUrl: 'logoUrl', config, parameters: [ - new CustomParameterDO({ + new CustomParameter({ name: 'name', regex: 'regex', displayName: 'displayName', @@ -168,21 +168,21 @@ describe('ExternalToolRepo', () => { }; it('should save an basic tool correctly', async () => { - const config: BasicToolConfigDO = new BasicToolConfigDO({ + const config: BasicToolConfig = new BasicToolConfig({ type: ToolConfigType.BASIC, baseUrl: 'baseUrl', }); const { domainObject } = setupDO(config); const { id, ...expected } = domainObject; - const result: ExternalToolDO = await repo.save(domainObject); + const result: ExternalTool = await repo.save(domainObject); expect(result).toMatchObject(expected); expect(result.id).toBeDefined(); }); it('should save an oauth2 tool correctly', async () => { - const config: Oauth2ToolConfigDO = new Oauth2ToolConfigDO({ + const config: Oauth2ToolConfig = new Oauth2ToolConfig({ type: ToolConfigType.BASIC, baseUrl: 'baseUrl', clientId: 'clientId', @@ -191,14 +191,14 @@ describe('ExternalToolRepo', () => { const { domainObject } = setupDO(config); const { id, ...expected } = domainObject; - const result: ExternalToolDO = await repo.save(domainObject); + const result: ExternalTool = await repo.save(domainObject); expect(result).toMatchObject(expected); expect(result.id).toBeDefined(); }); it('should save an lti11 tool correctly', async () => { - const config: Lti11ToolConfigDO = new Lti11ToolConfigDO({ + const config: Lti11ToolConfig = new Lti11ToolConfig({ type: ToolConfigType.BASIC, baseUrl: 'baseUrl', secret: 'secret', @@ -206,11 +206,12 @@ describe('ExternalToolRepo', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, resource_link_id: 'resource_link_id', + launch_presentation_locale: 'de-DE', }); const { domainObject } = setupDO(config); const { id, ...expected } = domainObject; - const result: ExternalToolDO = await repo.save(domainObject); + const result: ExternalTool = await repo.save(domainObject); expect(result).toMatchObject(expected); expect(result.id).toBeDefined(); @@ -222,13 +223,13 @@ describe('ExternalToolRepo', () => { const { queryExternalToolDO } = await setup(); queryExternalToolDO.name = '.'; - const options: IFindOptions = {}; + const options: IFindOptions = {}; - await em.nativeDelete(ExternalTool, {}); - const ltiToolA: ExternalTool = externalToolFactory.withName('A').buildWithId(); - const ltiToolB: ExternalTool = externalToolFactory.withName('B').buildWithId(); - const ltiToolC: ExternalTool = externalToolFactory.withName('B').buildWithId(); - const ltiTools: ExternalTool[] = [ltiToolA, ltiToolB, ltiToolC]; + await em.nativeDelete(ExternalToolEntity, {}); + const ltiToolA: ExternalToolEntity = externalToolEntityFactory.withName('A').buildWithId(); + const ltiToolB: ExternalToolEntity = externalToolEntityFactory.withName('B').buildWithId(); + const ltiToolC: ExternalToolEntity = externalToolEntityFactory.withName('B').buildWithId(); + const ltiTools: ExternalToolEntity[] = [ltiToolA, ltiToolB, ltiToolC]; await em.persistAndFlush([ltiToolA, ltiToolB, ltiToolC]); return { queryExternalToolDO, options, ltiTools }; @@ -238,7 +239,7 @@ describe('ExternalToolRepo', () => { it('should return all ltiTools when options with pagination is set to undefined', async () => { const { queryExternalToolDO, ltiTools } = await setupFind(); - const page: Page = await repo.find(queryExternalToolDO, undefined); + const page: Page = await repo.find(queryExternalToolDO, undefined); expect(page.data.length).toBe(ltiTools.length); }); @@ -247,7 +248,7 @@ describe('ExternalToolRepo', () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1 }; - const page: Page = await repo.find(queryExternalToolDO, options); + const page: Page = await repo.find(queryExternalToolDO, options); expect(page.data.length).toBe(1); }); @@ -256,7 +257,7 @@ describe('ExternalToolRepo', () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1, skip: 3 }; - const page: Page = await repo.find(queryExternalToolDO, options); + const page: Page = await repo.find(queryExternalToolDO, options); expect(page.data.length).toBe(0); }); @@ -266,7 +267,7 @@ describe('ExternalToolRepo', () => { it('should return ltiTools ordered by default _id when no order is specified', async () => { const { queryExternalToolDO, options, ltiTools } = await setupFind(); - const page: Page = await repo.find(queryExternalToolDO, options); + const page: Page = await repo.find(queryExternalToolDO, options); expect(page.data[0].name).toEqual(ltiTools[0].name); expect(page.data[1].name).toEqual(ltiTools[1].name); @@ -280,7 +281,7 @@ describe('ExternalToolRepo', () => { name: SortOrder.asc, }; - const page: Page = await repo.find(queryExternalToolDO, options); + const page: Page = await repo.find(queryExternalToolDO, options); expect(page.data[0].name).toEqual(ltiTools[0].name); expect(page.data[1].name).toEqual(ltiTools[1].name); diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index e7c38ee8b93..bcd319de14d 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -1,45 +1,47 @@ import { UnprocessableEntityException } from '@nestjs/common'; +import { CustomParameter, CustomParameterEntry } from '@src/modules/tool/common/domain'; +import { CustomParameterEntryEntity } from '@src/modules/tool/common/entity'; +import { ToolConfigType } from '@src/modules/tool/common/enum'; import { BasicToolConfig, - BasicToolConfigDO, - CustomParameter, - CustomParameterDO, - CustomParameterEntry, - CustomParameterEntryDO, ExternalTool, - ExternalToolDO, - IExternalToolProperties, Lti11ToolConfig, - Lti11ToolConfigDO, Oauth2ToolConfig, - Oauth2ToolConfigDO, - ToolConfigType, -} from '@shared/domain'; +} from '@src/modules/tool/external-tool/domain'; +import { + BasicToolConfigEntity, + CustomParameterEntity, + ExternalToolEntity, + IExternalToolProperties, + Lti11ToolConfigEntity, + Oauth2ToolConfigEntity, +} from '@src/modules/tool/external-tool/entity'; // TODO: maybe rename because of usage in external tool repo and school external tool repo export class ExternalToolRepoMapper { - static mapEntityToDO(entity: ExternalTool): ExternalToolDO { - let config: BasicToolConfigDO | Oauth2ToolConfigDO | Lti11ToolConfigDO; + static mapEntityToDO(entity: ExternalToolEntity): ExternalTool { + let config: BasicToolConfig | Oauth2ToolConfig | Lti11ToolConfig; switch (entity.config.type) { case ToolConfigType.BASIC: - config = this.mapBasicToolConfigToDO(entity.config as BasicToolConfigDO); + config = this.mapBasicToolConfigToDO(entity.config as BasicToolConfig); break; case ToolConfigType.OAUTH2: - config = this.mapOauth2ConfigToDO(entity.config as Oauth2ToolConfigDO); + config = this.mapOauth2ConfigToDO(entity.config as Oauth2ToolConfig); break; case ToolConfigType.LTI11: - config = this.mapLti11ToolConfigToDO(entity.config as Lti11ToolConfigDO); + config = this.mapLti11ToolConfigToDO(entity.config as Lti11ToolConfig); break; default: /* istanbul ignore next */ throw new UnprocessableEntityException(`Unknown config type.`); } - return new ExternalToolDO({ + return new ExternalTool({ id: entity.id, name: entity.name, url: entity.url, logoUrl: entity.logoUrl, + logo: entity.logoBase64, config, parameters: this.mapCustomParametersToDOs(entity.parameters || []), isHidden: entity.isHidden, @@ -48,15 +50,15 @@ export class ExternalToolRepoMapper { }); } - static mapBasicToolConfigToDO(lti11Config: BasicToolConfig): BasicToolConfigDO { - return new BasicToolConfigDO({ + static mapBasicToolConfigToDO(lti11Config: BasicToolConfigEntity): BasicToolConfig { + return new BasicToolConfig({ type: lti11Config.type, baseUrl: lti11Config.baseUrl, }); } - static mapOauth2ConfigToDO(oauth2Config: Oauth2ToolConfig): Oauth2ToolConfigDO { - return new Oauth2ToolConfigDO({ + static mapOauth2ConfigToDO(oauth2Config: Oauth2ToolConfigEntity): Oauth2ToolConfig { + return new Oauth2ToolConfig({ type: oauth2Config.type, baseUrl: oauth2Config.baseUrl, clientId: oauth2Config.clientId, @@ -64,8 +66,8 @@ export class ExternalToolRepoMapper { }); } - static mapLti11ToolConfigToDO(lti11Config: Lti11ToolConfig): Lti11ToolConfigDO { - return new Lti11ToolConfigDO({ + static mapLti11ToolConfigToDO(lti11Config: Lti11ToolConfigEntity): Lti11ToolConfig { + return new Lti11ToolConfig({ type: lti11Config.type, baseUrl: lti11Config.baseUrl, key: lti11Config.key, @@ -73,20 +75,21 @@ export class ExternalToolRepoMapper { lti_message_type: lti11Config.lti_message_type, resource_link_id: lti11Config.resource_link_id, privacy_permission: lti11Config.privacy_permission, + launch_presentation_locale: lti11Config.launch_presentation_locale, }); } - static mapDOToEntityProperties(entityDO: ExternalToolDO): IExternalToolProperties { - let config: BasicToolConfig | Oauth2ToolConfig | Lti11ToolConfig; + static mapDOToEntityProperties(entityDO: ExternalTool): IExternalToolProperties { + let config: BasicToolConfigEntity | Oauth2ToolConfigEntity | Lti11ToolConfigEntity; switch (entityDO.config.type) { case ToolConfigType.BASIC: - config = this.mapBasicToolConfigDOToEntity(entityDO.config as BasicToolConfigDO); + config = this.mapBasicToolConfigDOToEntity(entityDO.config as BasicToolConfig); break; case ToolConfigType.OAUTH2: - config = this.mapOauth2ConfigDOToEntity(entityDO.config as Oauth2ToolConfigDO); + config = this.mapOauth2ConfigDOToEntity(entityDO.config as Oauth2ToolConfig); break; case ToolConfigType.LTI11: - config = this.mapLti11ToolConfigDOToEntity(entityDO.config as Lti11ToolConfigDO); + config = this.mapLti11ToolConfigDOToEntity(entityDO.config as Lti11ToolConfig); break; default: /* istanbul ignore next */ @@ -97,6 +100,7 @@ export class ExternalToolRepoMapper { name: entityDO.name, url: entityDO.url, logoUrl: entityDO.logoUrl, + logoBase64: entityDO.logo, config, parameters: this.mapCustomParameterDOsToEntities(entityDO.parameters ?? []), isHidden: entityDO.isHidden, @@ -105,15 +109,15 @@ export class ExternalToolRepoMapper { }; } - static mapBasicToolConfigDOToEntity(lti11Config: BasicToolConfigDO): BasicToolConfig { - return new BasicToolConfig({ + static mapBasicToolConfigDOToEntity(lti11Config: BasicToolConfig): BasicToolConfigEntity { + return new BasicToolConfigEntity({ type: lti11Config.type, baseUrl: lti11Config.baseUrl, }); } - static mapOauth2ConfigDOToEntity(oauth2Config: Oauth2ToolConfigDO): Oauth2ToolConfig { - return new Oauth2ToolConfig({ + static mapOauth2ConfigDOToEntity(oauth2Config: Oauth2ToolConfig): Oauth2ToolConfigEntity { + return new Oauth2ToolConfigEntity({ type: oauth2Config.type, baseUrl: oauth2Config.baseUrl, clientId: oauth2Config.clientId, @@ -121,8 +125,8 @@ export class ExternalToolRepoMapper { }); } - static mapLti11ToolConfigDOToEntity(lti11Config: Lti11ToolConfigDO): Lti11ToolConfig { - return new Lti11ToolConfig({ + static mapLti11ToolConfigDOToEntity(lti11Config: Lti11ToolConfig): Lti11ToolConfigEntity { + return new Lti11ToolConfigEntity({ type: lti11Config.type, baseUrl: lti11Config.baseUrl, key: lti11Config.key, @@ -130,13 +134,14 @@ export class ExternalToolRepoMapper { lti_message_type: lti11Config.lti_message_type, resource_link_id: lti11Config.resource_link_id, privacy_permission: lti11Config.privacy_permission, + launch_presentation_locale: lti11Config.launch_presentation_locale, }); } - static mapCustomParametersToDOs(customParameters: CustomParameter[]): CustomParameterDO[] { + static mapCustomParametersToDOs(customParameters: CustomParameterEntity[]): CustomParameter[] { return customParameters.map( - (param: CustomParameter) => - new CustomParameterDO({ + (param: CustomParameterEntity) => + new CustomParameter({ name: param.name, displayName: param.displayName, description: param.description, @@ -151,10 +156,10 @@ export class ExternalToolRepoMapper { ); } - static mapCustomParameterDOsToEntities(customParameters: CustomParameterDO[]): CustomParameter[] { + static mapCustomParameterDOsToEntities(customParameters: CustomParameter[]): CustomParameterEntity[] { return customParameters.map( - (param: CustomParameterDO) => - new CustomParameter({ + (param: CustomParameter) => + new CustomParameterEntity({ name: param.name, displayName: param.displayName, description: param.description, @@ -169,20 +174,20 @@ export class ExternalToolRepoMapper { ); } - static mapCustomParameterEntryEntitiesToDOs(entries: CustomParameterEntry[]): CustomParameterEntryDO[] { + static mapCustomParameterEntryEntitiesToDOs(entries: CustomParameterEntryEntity[]): CustomParameterEntry[] { return entries.map( - (entry: CustomParameterEntry): CustomParameterEntryDO => - new CustomParameterEntryDO({ + (entry: CustomParameterEntryEntity): CustomParameterEntry => + new CustomParameterEntry({ name: entry.name, value: entry.value, }) ); } - static mapCustomParameterEntryDOsToEntities(entries: CustomParameterEntryDO[]): CustomParameterEntry[] { + static mapCustomParameterEntryDOsToEntities(entries: CustomParameterEntry[]): CustomParameterEntryEntity[] { return entries.map( - (entry: CustomParameterEntry): CustomParameterEntryDO => - new CustomParameterEntry({ + (entry: CustomParameterEntryEntity): CustomParameterEntry => + new CustomParameterEntryEntity({ name: entry.name, value: entry.value, }) diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.ts index 0d70a423537..05f3a2d47d1 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.ts @@ -1,68 +1,62 @@ import { EntityName, QueryOrderMap } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { - ExternalTool, - IExternalToolProperties, - IFindOptions, - IPagination, - Page, - SortOrder, - ToolConfigType, -} from '@shared/domain'; -import { ExternalToolDO } from '@shared/domain/domainobject/tool'; +import { IFindOptions, IPagination, Page, SortOrder } from '@shared/domain'; import { BaseDORepo, ExternalToolRepoMapper, ExternalToolSortingMapper, Scope } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; +import { ToolConfigType } from '@src/modules/tool/common/enum'; import { ExternalToolSearchQuery } from '@src/modules/tool/common/interface'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolEntity, IExternalToolProperties } from '@src/modules/tool/external-tool/entity'; import { ExternalToolScope } from './external-tool.scope'; @Injectable() -export class ExternalToolRepo extends BaseDORepo { +export class ExternalToolRepo extends BaseDORepo { constructor(protected readonly _em: EntityManager, protected readonly logger: LegacyLogger) { super(_em, logger); } - get entityName(): EntityName { - return ExternalTool; + get entityName(): EntityName { + return ExternalToolEntity; } - entityFactory(props: IExternalToolProperties): ExternalTool { - return new ExternalTool(props); + entityFactory(props: IExternalToolProperties): ExternalToolEntity { + return new ExternalToolEntity(props); } - async findByName(name: string): Promise { - const entity: ExternalTool | null = await this._em.findOne(this.entityName, { name }); + async findByName(name: string): Promise { + const entity: ExternalToolEntity | null = await this._em.findOne(this.entityName, { name }); if (entity !== null) { - const domainObject: ExternalToolDO = this.mapEntityToDO(entity); + const domainObject: ExternalTool = this.mapEntityToDO(entity); return domainObject; } return null; } - async findAllByConfigType(type: ToolConfigType): Promise { - const entities: ExternalTool[] = await this._em.find(this.entityName, { config: { type } }); - const domainObjects: ExternalToolDO[] = entities.map((entity: ExternalTool): ExternalToolDO => { - const domainObject: ExternalToolDO = this.mapEntityToDO(entity); + async findAllByConfigType(type: ToolConfigType): Promise { + const entities: ExternalToolEntity[] = await this._em.find(this.entityName, { config: { type } }); + const domainObjects: ExternalTool[] = entities.map((entity: ExternalToolEntity): ExternalTool => { + const domainObject: ExternalTool = this.mapEntityToDO(entity); return domainObject; }); return domainObjects; } - async findByOAuth2ConfigClientId(clientId: string): Promise { - const entity: ExternalTool | null = await this._em.findOne(this.entityName, { config: { clientId } }); + async findByOAuth2ConfigClientId(clientId: string): Promise { + const entity: ExternalToolEntity | null = await this._em.findOne(this.entityName, { config: { clientId } }); if (entity !== null) { - const domainObject: ExternalToolDO = this.mapEntityToDO(entity); + const domainObject: ExternalTool = this.mapEntityToDO(entity); return domainObject; } return null; } - async find(query: ExternalToolSearchQuery, options?: IFindOptions): Promise> { + async find(query: ExternalToolSearchQuery, options?: IFindOptions): Promise> { const pagination: IPagination = options?.pagination || {}; - const order: QueryOrderMap = ExternalToolSortingMapper.mapDOSortOrderToQueryOrder( + const order: QueryOrderMap = ExternalToolSortingMapper.mapDOSortOrderToQueryOrder( options?.order || {} ); - const scope: Scope = new ExternalToolScope() + const scope: Scope = new ExternalToolScope() .byName(query.name) .byClientId(query.clientId) .byHidden(query.isHidden) @@ -72,22 +66,30 @@ export class ExternalToolRepo extends BaseDORepo this.mapEntityToDO(entity)); - const page: Page = new Page(entityDos, total); + const entityDos: ExternalTool[] = entities.map((entity) => this.mapEntityToDO(entity)); + const page: Page = new Page(entityDos, total); return page; } - mapEntityToDO(entity: ExternalTool): ExternalToolDO { - return ExternalToolRepoMapper.mapEntityToDO(entity); + mapEntityToDO(entity: ExternalToolEntity): ExternalTool { + const domainObject = ExternalToolRepoMapper.mapEntityToDO(entity); + + return domainObject; } - mapDOToEntityProperties(entityDO: ExternalToolDO): IExternalToolProperties { - return ExternalToolRepoMapper.mapDOToEntityProperties(entityDO); + mapDOToEntityProperties(entityDO: ExternalTool): IExternalToolProperties { + const entity = ExternalToolRepoMapper.mapDOToEntityProperties(entityDO); + + return entity; } } diff --git a/apps/server/src/shared/repo/externaltool/external-tool.scope.ts b/apps/server/src/shared/repo/externaltool/external-tool.scope.ts index 2859260cd74..38e7fbf7754 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.scope.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.scope.ts @@ -1,7 +1,7 @@ -import { ExternalTool } from '@shared/domain/entity/tools/external-tool/external-tool.entity'; import { Scope } from '@shared/repo/scope'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; -export class ExternalToolScope extends Scope { +export class ExternalToolScope extends Scope { byName(name: string | undefined): this { if (name) { this.addQuery({ name: { $re: name } }); diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index 6cab3af7945..059ab24bd18 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -7,7 +7,6 @@ export * from './base.do.repo'; export * from './base.repo'; export * from './board'; -export * from './card'; export * from './course'; export * from './coursegroup'; export * from './dashboard'; 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 3b7c2c3220e..020a3440fcb 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,18 +1,21 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { - CustomParameterEntryDO, - ExternalTool, - type School, - SchoolExternalTool, - SchoolExternalToolDO, -} from '@shared/domain'; +import { type School } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; -import { cleanupCollections, externalToolFactory, schoolExternalToolFactory, schoolFactory } from '@shared/testing'; +import { + cleanupCollections, + externalToolEntityFactory, + schoolExternalToolEntityFactory, + schoolFactory, +} from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { createMock } from '@golevelup/ts-jest'; import { SchoolExternalToolQuery } from '@src/modules/tool/school-external-tool/uc/dto/school-external-tool.types'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { CustomParameterEntry } from '@src/modules/tool/common/domain'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; import { SchoolExternalToolRepo } from './school-external-tool.repo'; describe('SchoolExternalToolRepo', () => { @@ -46,40 +49,40 @@ describe('SchoolExternalToolRepo', () => { }); const createTools = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); const school: School = schoolFactory.buildWithId(); - const schoolExternalTool1: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, + const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, }); - const schoolExternalTool2: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const schoolExternalTool3: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - tool: externalTool, + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); + const schoolExternalTool3: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, }); - return { externalTool, school, schoolExternalTool1, schoolExternalTool2, schoolExternalTool3 }; + return { externalToolEntity, school, schoolExternalTool1, schoolExternalTool2, schoolExternalTool3 }; }; it('getEntityName should return SchoolExternalTool', () => { const { entityName } = repo; - expect(entityName).toEqual(SchoolExternalTool); + expect(entityName).toEqual(SchoolExternalToolEntity); }); describe('deleteByToolId', () => { const setup = async () => { - const { externalTool, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); + const { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); - await em.persistAndFlush([school, externalTool, schoolExternalTool1, schoolExternalTool3]); + await em.persistAndFlush([school, externalToolEntity, schoolExternalTool1, schoolExternalTool3]); em.clear(); - return { externalTool, school, schoolExternalTool1, schoolExternalTool3 }; + return { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 }; }; it('should delete all SchoolExternalTools with reference to a given ExternalTool', async () => { - const { externalTool } = await setup(); + const { externalToolEntity } = await setup(); - const result: number = await repo.deleteByExternalToolId(externalTool.id); + const result: number = await repo.deleteByExternalToolId(externalToolEntity.id); expect(result).toEqual(2); }); @@ -87,18 +90,18 @@ describe('SchoolExternalToolRepo', () => { describe('findByToolId', () => { const setup = async () => { - const { externalTool, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); + const { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); - await em.persistAndFlush([school, externalTool, schoolExternalTool1, schoolExternalTool3]); + await em.persistAndFlush([school, externalToolEntity, schoolExternalTool1, schoolExternalTool3]); em.clear(); - return { externalTool, school, schoolExternalTool1, schoolExternalTool3 }; + return { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 }; }; it('should find all SchoolExternalTools with reference to a given ExternalTool', async () => { - const { externalTool, schoolExternalTool1, schoolExternalTool3 } = await setup(); + const { externalToolEntity, schoolExternalTool1, schoolExternalTool3 } = await setup(); - const result: SchoolExternalToolDO[] = await repo.findByExternalToolId(externalTool.id); + const result: SchoolExternalTool[] = await repo.findByExternalToolId(externalToolEntity.id); expect(result).toEqual( expect.arrayContaining([ @@ -112,18 +115,18 @@ describe('SchoolExternalToolRepo', () => { describe('findBySchoolId', () => { describe('when searching for SchoolExternalTools by school id', () => { const setup = async () => { - const { externalTool, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); + const { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 } = createTools(); - await em.persistAndFlush([school, externalTool, schoolExternalTool1, schoolExternalTool3]); + await em.persistAndFlush([school, externalToolEntity, schoolExternalTool1, schoolExternalTool3]); em.clear(); - return { externalTool, school, schoolExternalTool1, schoolExternalTool3 }; + return { externalToolEntity, school, schoolExternalTool1, schoolExternalTool3 }; }; it('should find all SchoolExternalTools with reference to a given school id', async () => { const { school, schoolExternalTool1, schoolExternalTool3 } = await setup(); - const result: SchoolExternalToolDO[] = await repo.findBySchoolId(school.id); + const result: SchoolExternalTool[] = await repo.findBySchoolId(school.id); expect(result).toEqual( expect.arrayContaining([ @@ -137,9 +140,9 @@ describe('SchoolExternalToolRepo', () => { describe('save', () => { function setup() { - const domainObject: SchoolExternalToolDO = new SchoolExternalToolDO({ + const domainObject: SchoolExternalTool = new SchoolExternalTool({ toolId: new ObjectId().toHexString(), - parameters: [new CustomParameterEntryDO({ name: 'param', value: 'value' })], + parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], schoolId: new ObjectId().toHexString(), toolVersion: 1, }); @@ -153,7 +156,7 @@ describe('SchoolExternalToolRepo', () => { const { domainObject } = setup(); const { id, ...expected } = domainObject; - const result: SchoolExternalToolDO = await repo.save(domainObject); + const result: SchoolExternalTool = await repo.save(domainObject); expect(result).toMatchObject(expected); expect(result.id).toBeDefined(); @@ -178,7 +181,7 @@ describe('SchoolExternalToolRepo', () => { it('should return a do', async () => { const { query, schoolExternalTool1 } = await setup(); - const result: SchoolExternalToolDO[] = await repo.find(query); + const result: SchoolExternalTool[] = await repo.find(query); expect(result[0].schoolId).toEqual(schoolExternalTool1.school.id); }); @@ -186,13 +189,13 @@ describe('SchoolExternalToolRepo', () => { describe('when tool is set', () => { const setup = async () => { - const { school, externalTool, schoolExternalTool1 } = createTools(); + const { school, externalToolEntity, schoolExternalTool1 } = createTools(); - await em.persistAndFlush([school, externalTool, schoolExternalTool1]); + await em.persistAndFlush([school, externalToolEntity, schoolExternalTool1]); em.clear(); const query: SchoolExternalToolQuery = { - toolId: externalTool.id, + toolId: externalToolEntity.id, }; return { query, schoolExternalTool1 }; @@ -201,7 +204,7 @@ describe('SchoolExternalToolRepo', () => { it('should return a do', async () => { const { query, schoolExternalTool1 } = await setup(); - const result: SchoolExternalToolDO[] = await repo.find(query); + const result: SchoolExternalTool[] = await repo.find(query); expect(result[0].toolId).toEqual(schoolExternalTool1.tool.id); }); @@ -225,7 +228,7 @@ describe('SchoolExternalToolRepo', () => { it('should return all dos', async () => { const { query } = await setup(); - const result: SchoolExternalToolDO[] = await repo.find(query); + const result: SchoolExternalTool[] = await repo.find(query); expect(result.length).toBeGreaterThan(0); }); diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts index c336cd07423..e53221fe370 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts @@ -1,50 +1,47 @@ import { EntityName } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { - ExternalTool, - ISchoolExternalToolProperties, - School, - SchoolExternalTool, - SchoolExternalToolDO, -} from '@shared/domain'; +import { School } from '@shared/domain'; import { BaseDORepo } from '@shared/repo/base.do.repo'; import { LegacyLogger } from '@src/core/logger'; import { SchoolExternalToolQuery } from '@src/modules/tool/school-external-tool/uc/dto/school-external-tool.types'; +import { ISchoolExternalToolProperties, SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; import { SchoolExternalToolScope } from './school-external-tool.scope'; import { ExternalToolRepoMapper } from '../externaltool'; @Injectable() export class SchoolExternalToolRepo extends BaseDORepo< - SchoolExternalToolDO, SchoolExternalTool, + SchoolExternalToolEntity, ISchoolExternalToolProperties > { constructor(protected readonly _em: EntityManager, protected readonly logger: LegacyLogger) { super(_em, logger); } - get entityName(): EntityName { - return SchoolExternalTool; + get entityName(): EntityName { + return SchoolExternalToolEntity; } - entityFactory(props: ISchoolExternalToolProperties): SchoolExternalTool { - return new SchoolExternalTool(props); + entityFactory(props: ISchoolExternalToolProperties): SchoolExternalToolEntity { + return new SchoolExternalToolEntity(props); } - async findByExternalToolId(toolId: string): Promise { - const entities: SchoolExternalTool[] = await this._em.find(this.entityName, { tool: toolId }); - const domainObjects: SchoolExternalToolDO[] = entities.map((entity: SchoolExternalTool): SchoolExternalToolDO => { - const domainObject: SchoolExternalToolDO = this.mapEntityToDO(entity); + async findByExternalToolId(toolId: string): Promise { + const entities: SchoolExternalToolEntity[] = await this._em.find(this.entityName, { tool: toolId }); + const domainObjects: SchoolExternalTool[] = entities.map((entity: SchoolExternalToolEntity): SchoolExternalTool => { + const domainObject: SchoolExternalTool = this.mapEntityToDO(entity); return domainObject; }); return domainObjects; } - async findBySchoolId(schoolId: string): Promise { - const entities: SchoolExternalTool[] = await this._em.find(this.entityName, { school: schoolId }); - const domainObjects: SchoolExternalToolDO[] = entities.map((entity: SchoolExternalTool): SchoolExternalToolDO => { - const domainObject: SchoolExternalToolDO = this.mapEntityToDO(entity); + async findBySchoolId(schoolId: string): Promise { + const entities: SchoolExternalToolEntity[] = await this._em.find(this.entityName, { school: schoolId }); + const domainObjects: SchoolExternalTool[] = entities.map((entity: SchoolExternalToolEntity): SchoolExternalTool => { + const domainObject: SchoolExternalTool = this.mapEntityToDO(entity); return domainObject; }); return domainObjects; @@ -55,12 +52,12 @@ export class SchoolExternalToolRepo extends BaseDORepo< return count; } - async find(query: SchoolExternalToolQuery): Promise { + async find(query: SchoolExternalToolQuery): Promise { const scope: SchoolExternalToolScope = this.buildScope(query); - const entities: SchoolExternalTool[] = await this._em.find(this.entityName, scope.query); + const entities: SchoolExternalToolEntity[] = await this._em.find(this.entityName, scope.query); - const dos: SchoolExternalToolDO[] = entities.map((entity: SchoolExternalTool) => this.mapEntityToDO(entity)); + const dos: SchoolExternalTool[] = entities.map((entity: SchoolExternalToolEntity) => this.mapEntityToDO(entity)); return dos; } @@ -74,8 +71,8 @@ export class SchoolExternalToolRepo extends BaseDORepo< return scope; } - mapEntityToDO(entity: SchoolExternalTool): SchoolExternalToolDO { - return new SchoolExternalToolDO({ + mapEntityToDO(entity: SchoolExternalToolEntity): SchoolExternalTool { + return new SchoolExternalTool({ id: entity.id, toolId: entity.tool.id, schoolId: entity.school.id, @@ -84,10 +81,10 @@ export class SchoolExternalToolRepo extends BaseDORepo< }); } - mapDOToEntityProperties(entityDO: SchoolExternalToolDO): ISchoolExternalToolProperties { + mapDOToEntityProperties(entityDO: SchoolExternalTool): ISchoolExternalToolProperties { return { school: this._em.getReference(School, entityDO.schoolId), - tool: this._em.getReference(ExternalTool, entityDO.toolId), + tool: this._em.getReference(ExternalToolEntity, entityDO.toolId), toolVersion: entityDO.toolVersion, schoolParameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(entityDO.parameters), }; diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts index a1cc1efbc29..89ca466b72a 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts @@ -1,7 +1,8 @@ import { Scope } from '@shared/repo/scope'; -import { EntityId, SchoolExternalTool } from '@shared/domain'; +import { EntityId } from '@shared/domain'; +import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; -export class SchoolExternalToolScope extends Scope { +export class SchoolExternalToolScope extends Scope { bySchoolId(schoolId: EntityId | undefined): this { if (schoolId !== undefined) { this.addQuery({ school: schoolId }); diff --git a/apps/server/src/shared/repo/task/task-scope.ts b/apps/server/src/shared/repo/task/task-scope.ts index d44af7e1a66..200c40b0aad 100644 --- a/apps/server/src/shared/repo/task/task-scope.ts +++ b/apps/server/src/shared/repo/task/task-scope.ts @@ -14,16 +14,6 @@ export class TaskScope extends Scope { return this; } - byAssignedUser(assignedUserId: EntityId): TaskScope { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.addQuery({ - $or: [{ users: { $exists: false } }, { users: { $eq: [] } }, { users: { $in: [assignedUserId] } }], - }); - - return this; - } - byOnlyCreatorId(creatorId: EntityId): TaskScope { this.addQuery({ $and: [{ creator: creatorId }, { course: null }, { lesson: null }], 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 48841740089..52f20ab2cab 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 @@ -606,42 +606,6 @@ describe('TaskRepo', () => { expect(result[0].name).toEqual(task1.name); }); }); - - describe('when userId (assigned users) filter is applied', () => { - const setup = async () => { - const teacher = userFactory.build(); - const student1 = userFactory.build(); - const student2 = userFactory.build(); - const course = courseFactory.build({ teachers: [teacher], students: [student1, student2] }); - const task1 = taskFactory.build({ course, creator: teacher, users: [student1, student2] }); - const task2 = taskFactory.build({ course, creator: teacher, users: [student2] }); - await em.persistAndFlush([task1, task2]); - em.clear(); - return { teacher, student1, student2, course, task1, task2 }; - }; - - it('should filter tasks by assigned user', async () => { - const { student1, student2, course } = await setup(); - - const [, totalStudent1] = await repo.findAllByParentIds({ courseIds: [course.id] }, { userId: student1.id }); - - const [, totalStudent2] = await repo.findAllByParentIds({ courseIds: [course.id] }, { userId: student2.id }); - - expect(totalStudent1).toEqual(1); - expect(totalStudent2).toEqual(2); - }); - - it('should return tasks when userId filter is not applied', async () => { - const { course } = await setup(); - - const [, totalStudent1] = await repo.findAllByParentIds({ courseIds: [course.id] }, {}); - - const [, totalStudent2] = await repo.findAllByParentIds({ courseIds: [course.id] }); - - expect(totalStudent1).toEqual(2); - expect(totalStudent2).toEqual(2); - }); - }); }); describe('order', () => { @@ -896,126 +860,6 @@ describe('TaskRepo', () => { }); describe('findAllFinishedByParentIds', () => { - describe('with filters', () => { - describe('with userId filter (filter for assigned user)', () => { - const setup = () => { - const teacher = userFactory.build(); - const student1 = userFactory.build(); - const student2 = userFactory.build(); - const course = courseFactory.build(); - - const task1 = taskFactory.build({ - creator: teacher, - course, - finished: [teacher, student1, student2], - users: [student1, student2], - }); - const task2 = taskFactory.build({ - creator: teacher, - course, - finished: [teacher, student1, student2], - users: [student2], - }); - const task3 = taskFactory.build({ - creator: teacher, - course, - finished: [teacher, student1, student2], - }); - const task4 = taskFactory.build({ - creator: teacher, - course, - finished: [], - users: [student1, student2], - }); - const task5 = taskFactory.build({ - creator: teacher, - course, - finished: [teacher, student1, student2], - users: [], - }); - - return { teacher, student1, student2, course, task1, task2, task3, task4, task5 }; - }; - - it('should return finished tasks for assigned users', async () => { - const { course, student1, student2, task1, task2, task3 } = setup(); - - await em.persistAndFlush([task1, task2, task3]); - em.clear(); - - const [, totalStudent1] = await repo.findAllFinishedByParentIds( - { - creatorId: student1.id, - openCourseIds: [course.id], - lessonIdsOfOpenCourses: [], - finishedCourseIds: [], - lessonIdsOfFinishedCourses: [], - }, - { - userId: student1.id, - } - ); - expect(totalStudent1).toEqual(2); - const [, totalStudent2] = await repo.findAllFinishedByParentIds( - { - creatorId: student1.id, - openCourseIds: [course.id], - lessonIdsOfOpenCourses: [], - finishedCourseIds: [], - lessonIdsOfFinishedCourses: [], - }, - { - userId: student2.id, - } - ); - expect(totalStudent2).toEqual(3); - }); - - it('should should not return finished tasks to which student is assigned, but are not finished', async () => { - const { course, teacher, student1, task4 } = setup(); - - await em.persistAndFlush([task4]); - em.clear(); - - const [, totalStudent1] = await repo.findAllFinishedByParentIds( - { - creatorId: teacher.id, - openCourseIds: [course.id], - lessonIdsOfOpenCourses: [], - finishedCourseIds: [], - lessonIdsOfFinishedCourses: [], - }, - { - userId: student1.id, - } - ); - expect(totalStudent1).toEqual(0); - }); - - it('should not return tasks finished, but for which student is not assigned (should not happen, but data could be inconsistent)', async () => { - const { course, teacher, student1, task2 } = setup(); - - await em.persistAndFlush([task2]); - em.clear(); - - const [data] = await repo.findAllFinishedByParentIds( - { - creatorId: teacher.id, - openCourseIds: [course.id], - lessonIdsOfOpenCourses: [], - finishedCourseIds: [], - lessonIdsOfFinishedCourses: [], - }, - { - userId: student1.id, - } - ); - const taskIds = data.map((task) => task.id); - expect(taskIds).not.toContain(task2.id); - }); - }); - }); - describe('given populates are set correctly', () => { describe('when task parent is a user', () => { const setup = async () => { @@ -1657,7 +1501,6 @@ describe('TaskRepo', () => { finishedCourseIds: [], lessonIdsOfFinishedCourses: [], }, - {}, { pagination: { skip: 5 } } ); @@ -1679,7 +1522,6 @@ describe('TaskRepo', () => { finishedCourseIds: [], lessonIdsOfFinishedCourses: [], }, - {}, { pagination: { limit: 5 } } ); @@ -1701,7 +1543,6 @@ describe('TaskRepo', () => { finishedCourseIds: [], lessonIdsOfFinishedCourses: [], }, - {}, { pagination: { limit: 5, skip: 5 } } ); @@ -1731,7 +1572,6 @@ describe('TaskRepo', () => { finishedCourseIds: [], lessonIdsOfFinishedCourses: [], }, - {}, { order: { dueDate: SortOrder.desc } } ); @@ -2136,42 +1976,6 @@ describe('TaskRepo', () => { expect(tasks).toHaveLength(0); }); }); - - describe('when filter by assigned user', () => { - const setup = async () => { - const teacher = userFactory.build(); - const student1 = userFactory.build(); - const student2 = userFactory.build(); - const course = courseFactory.build({ teachers: [teacher], students: [student1, student2] }); - const task1 = taskFactory.build({ course, creator: teacher, users: [student1, student2] }); - const task2 = taskFactory.build({ course, creator: teacher, users: [student2] }); - await em.persistAndFlush([task1, task2]); - em.clear(); - return { teacher, student1, student2, course, task1, task2 }; - }; - - it('should filter tasks by assigned user', async () => { - const { student1, student2, course } = await setup(); - - const [, totalStudent1] = await repo.findBySingleParent(student1.id, course.id, { userId: student1.id }); - - const [, totalStudent2] = await repo.findBySingleParent(student2.id, course.id, { userId: student2.id }); - - expect(totalStudent1).toEqual(1); - expect(totalStudent2).toEqual(2); - }); - - it('should return tasks when userId filter is not applied', async () => { - const { student1, student2, course } = await setup(); - - const [, totalStudent1] = await repo.findBySingleParent(student1.id, course.id, {}); - - const [, totalStudent2] = await repo.findBySingleParent(student2.id, course.id); - - expect(totalStudent1).toEqual(2); - expect(totalStudent2).toEqual(2); - }); - }); }); describe('findById', () => { diff --git a/apps/server/src/shared/repo/task/task.repo.ts b/apps/server/src/shared/repo/task/task.repo.ts index 5207f8ee554..094d21a8dc5 100644 --- a/apps/server/src/shared/repo/task/task.repo.ts +++ b/apps/server/src/shared/repo/task/task.repo.ts @@ -20,7 +20,6 @@ export class TaskRepo extends BaseRepo { 'lesson.courseGroup', 'submissions', 'submissions.courseGroup', - 'users', ]); } @@ -44,9 +43,6 @@ export class TaskRepo extends BaseRepo { finishedCourseIds: EntityId[]; lessonIdsOfFinishedCourses: EntityId[]; }, - filters?: { - userId?: EntityId; - }, options?: IFindOptions ): Promise> { const scope = new TaskScope('$or'); @@ -91,20 +87,7 @@ export class TaskRepo extends BaseRepo { scope.addQuery(allForFinishedCoursesAndLessons.query); scope.addQuery(allForCreator.query); - let { query } = scope; - - if (filters?.userId) { - const forAssignedUser = new TaskScope(); - forAssignedUser.byAssignedUser(filters.userId); - - const filtersScope = new TaskScope('$and'); - filtersScope.addQuery(forAssignedUser.query); - filtersScope.addQuery(scope.query); - - ({ query } = filtersScope); - } - - const countedTaskList = await this.findTasksAndCount(query, options); + const countedTaskList = await this.findTasksAndCount(scope.query, options); return countedTaskList; } @@ -130,7 +113,6 @@ export class TaskRepo extends BaseRepo { afterDueDateOrNone?: Date; finished?: { userId: EntityId; value: boolean }; availableOn?: Date; - userId?: EntityId; }, options?: IFindOptions ): Promise> { @@ -152,10 +134,6 @@ export class TaskRepo extends BaseRepo { scope.addQuery(parentIdScope.query); - if (filters?.userId) { - scope.byAssignedUser(filters.userId); - } - if (filters?.finished) { scope.byFinished(filters.finished.userId, filters.finished.value); } @@ -186,7 +164,7 @@ export class TaskRepo extends BaseRepo { async findBySingleParent( creatorId: EntityId, courseId: EntityId, - filters?: { draft?: boolean; noFutureAvailableDate?: boolean; userId?: EntityId }, + filters?: { draft?: boolean; noFutureAvailableDate?: boolean }, options?: IFindOptions ): Promise> { const scope = new TaskScope(); @@ -204,10 +182,6 @@ export class TaskRepo extends BaseRepo { scope.noFutureAvailableDate(); } - if (filters?.userId) { - scope.byAssignedUser(filters.userId); - } - const countedTaskList = await this.findTasksAndCount(scope.query, options); return countedTaskList; 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 259402b8e5d..8d68ea8cf21 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 @@ -1,7 +1,7 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityId, Team } from '@shared/domain'; +import { EntityId, TeamEntity, TeamUserEntity } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { TeamsRepo } from '@shared/repo'; import { cleanupCollections, roleFactory } from '@shared/testing'; @@ -37,7 +37,7 @@ describe('team repo', () => { }); it('should implement entityName getter', () => { - expect(repo.entityName).toBe(Team); + expect(repo.entityName).toBe(TeamEntity); }); describe('findById', () => { @@ -64,7 +64,7 @@ describe('team repo', () => { const role = roleFactory.build({ roles: roles2 }); await em.persistAndFlush(role); - const team: Team = teamFactory.withRoleAndUserId(role, userId).buildWithId(); + const team: TeamEntity = teamFactory.withRoleAndUserId(role, userId).buildWithId(); await em.persistAndFlush(team); em.clear(); @@ -118,8 +118,8 @@ describe('team repo', () => { it('should return teams which contains a specific userId', async () => { // Arrange const teamUser = teamUserFactory.buildWithId(); - const team1 = teamFactory.withTeamUser(teamUser).build(); - const team2 = teamFactory.withTeamUser(teamUser).build(); + const team1 = teamFactory.withTeamUser([teamUser]).build(); + const team2 = teamFactory.withTeamUser([teamUser]).build(); const team3 = teamFactory.buildWithId(); await em.persistAndFlush([team1, team2, team3]); em.clear(); @@ -129,11 +129,45 @@ describe('team repo', () => { // Assert expect(result.length).toEqual([team1, team2].length); - result.forEach((team: Team) => { + result.forEach((team: TeamEntity) => { expect(team.teamUsers.flatMap((user) => user.userId.id).includes(teamUser.userId.id)).toBeTruthy(); }); - expect(result.some((team: Team) => team.id === team3.id)).toBeFalsy(); + expect(result.some((team: TeamEntity) => team.id === team3.id)).toBeFalsy(); expect(Object.keys(result[0]).sort()).toEqual(['name', 'userIds', 'updatedAt', '_id', 'createdAt'].sort()); }); }); + + describe('updateTeams', () => { + it('should update teams without deleted user', async () => { + // Arrange + const teamUser1: TeamUserEntity = teamUserFactory.buildWithId(); + const teamUser2: TeamUserEntity = teamUserFactory.buildWithId(); + const teamUser3: TeamUserEntity = teamUserFactory.buildWithId(); + const team1 = teamFactory.withTeamUser([teamUser1, teamUser2]).buildWithId(); + const team2 = teamFactory.withTeamUser([teamUser1, teamUser2, teamUser3]).buildWithId(); + const team3 = teamFactory.withTeamUser([teamUser1]).buildWithId(); + const team4 = teamFactory.withTeamUser([teamUser3]).buildWithId(); + + await em.persistAndFlush([team1, team2, team3, team4]); + em.clear(); + + // Arrange Team Array after teamUser1 deletion + team1.teamUsers = [teamUser2]; + team2.teamUsers = [teamUser2, teamUser3]; + team3.teamUsers = []; + const updatedArray: TeamEntity[] = [team1, team2, team3]; + + // Act + await repo.save(updatedArray); + + const result1 = await repo.findByUserId(teamUser1.user.id); + expect(result1).toHaveLength(0); + + const result2 = await repo.findByUserId(teamUser2.user.id); + expect(result2).toHaveLength(2); + + const result3 = await repo.findByUserId(teamUser3.user.id); + expect(result3).toHaveLength(2); + }); + }); }); diff --git a/apps/server/src/shared/repo/teams/teams.repo.ts b/apps/server/src/shared/repo/teams/teams.repo.ts index 07c92ca4903..ef4d5e9774b 100644 --- a/apps/server/src/shared/repo/teams/teams.repo.ts +++ b/apps/server/src/shared/repo/teams/teams.repo.ts @@ -1,22 +1,22 @@ import { Injectable } from '@nestjs/common'; -import { EntityId, Role, Team, TeamUser } from '@shared/domain'; +import { EntityId, Role, TeamEntity, TeamUserEntity } from '@shared/domain'; import { ObjectId } from '@mikro-orm/mongodb'; import { BaseRepo } from '../base.repo'; @Injectable() -export class TeamsRepo extends BaseRepo { +export class TeamsRepo extends BaseRepo { get entityName() { - return Team; + return TeamEntity; } cacheExpiration = 60000; - async findById(id: EntityId, populate = false): Promise { - const team = await this._em.findOneOrFail(Team, { id }, { cache: this.cacheExpiration }); + async findById(id: EntityId, populate = false): Promise { + const team = await this._em.findOneOrFail(TeamEntity, { id }, { cache: this.cacheExpiration }); if (populate) { await Promise.all( - team.teamUsers.map(async (teamUser: TeamUser): Promise => { + team.teamUsers.map(async (teamUser: TeamUserEntity): Promise => { await this._em.populate(teamUser, ['role']); await this.populateRoles([teamUser.role]); }) @@ -32,8 +32,10 @@ export class TeamsRepo extends BaseRepo { * @param userId * @return Array of teams */ - async findByUserId(userId: EntityId): Promise { - const teams: Team[] = await this._em.find(Team, { userIds: { userId: new ObjectId(userId) } }); + async findByUserId(userId: EntityId): Promise { + const teams: TeamEntity[] = await this._em.find(TeamEntity, { + userIds: { userId: new ObjectId(userId) }, + }); return teams; } diff --git a/apps/server/src/shared/testing/factory/account.factory.ts b/apps/server/src/shared/testing/factory/account.factory.ts index df0f261a83c..b0c0b8434c1 100644 --- a/apps/server/src/shared/testing/factory/account.factory.ts +++ b/apps/server/src/shared/testing/factory/account.factory.ts @@ -1,13 +1,13 @@ /* istanbul ignore file */ -import { Account, EntityId, IAccountProperties, User } from '@shared/domain'; +import { Account, EntityId, IdmAccountProperties, User } from '@shared/domain'; import { ObjectId } from 'bson'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; -class AccountFactory extends BaseFactory { +class AccountFactory extends BaseFactory { withSystemId(id: EntityId | ObjectId): this { - const params: DeepPartial = { systemId: id }; + const params: DeepPartial = { systemId: id }; return this.params(params); } @@ -17,7 +17,7 @@ class AccountFactory extends BaseFactory { throw new Error('User does not have an id.'); } - const params: DeepPartial = { userId: user.id }; + const params: DeepPartial = { userId: user.id }; return this.params(params); } diff --git a/apps/server/src/shared/testing/factory/axios-response.factory.ts b/apps/server/src/shared/testing/factory/axios-response.factory.ts new file mode 100644 index 00000000000..5273c310e8c --- /dev/null +++ b/apps/server/src/shared/testing/factory/axios-response.factory.ts @@ -0,0 +1,45 @@ +import { AxiosHeaderValue, AxiosHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { BaseFactory } from './base.factory'; + +export type AxiosHeadersKeyValue = { [key: string]: AxiosHeaderValue }; +type AxiosResponseProps = { + data: T; + status: number; + statusText: string; + headers: AxiosHeadersKeyValue; + config: InternalAxiosRequestConfig; +}; + +class AxiosResponseImp implements AxiosResponse { + data: T; + + status: number; + + statusText: string; + + headers: AxiosHeaders; + + config: InternalAxiosRequestConfig; + + constructor(props: AxiosResponseProps) { + this.data = props.data; + this.status = props.status; + this.statusText = props.statusText; + this.headers = new AxiosHeaders(props.headers); + this.config = props.config; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const axiosResponseFactory = BaseFactory.define, AxiosResponseProps>( + AxiosResponseImp, + () => { + return { + data: '', + status: 200, + statusText: '', + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/card-element.factory.ts b/apps/server/src/shared/testing/factory/card-element.factory.ts deleted file mode 100644 index fe436ce8bbb..00000000000 --- a/apps/server/src/shared/testing/factory/card-element.factory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { InputFormat, RichText, RichTextCardElement } from '@shared/domain'; -import { BaseFactory } from './base.factory'; - -export const richTextCardElementFactory = BaseFactory.define( - RichTextCardElement, - ({ sequence }) => { - const richText = new RichText({ - type: InputFormat.RICH_TEXT_CK5, - content: `rich text ck5 card element #${sequence}`, - }); - return richText; - } -); diff --git a/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts new file mode 100644 index 00000000000..1299340095e --- /dev/null +++ b/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts @@ -0,0 +1,23 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { CustomParameterEntryEntity } from '@src/modules/tool/common/entity'; +import { + ContextExternalToolEntity, + ContextExternalToolType, + IContextExternalToolProperties, +} from '@src/modules/tool/context-external-tool/entity'; +import { courseFactory } from './course.factory'; +import { schoolExternalToolEntityFactory } from './school-external-tool-entity.factory'; + +export const contextExternalToolEntityFactory = BaseFactory.define< + ContextExternalToolEntity, + IContextExternalToolProperties +>(ContextExternalToolEntity, () => { + return { + contextId: courseFactory.buildWithId().id, + contextType: ContextExternalToolType.COURSE, + displayName: 'My Course Tool 1', + schoolTool: schoolExternalToolEntityFactory.buildWithId(), + parameters: [new CustomParameterEntryEntity({ name: 'mockParamater', value: 'mockValue' })], + toolVersion: 1, + }; +}); diff --git a/apps/server/src/shared/testing/factory/context-external-tool.factory.ts b/apps/server/src/shared/testing/factory/context-external-tool.factory.ts deleted file mode 100644 index 901230b5da2..00000000000 --- a/apps/server/src/shared/testing/factory/context-external-tool.factory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - ContextExternalTool, - ContextExternalToolType, - CustomParameterEntry, - IContextExternalToolProperties, -} from '@shared/domain'; -import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { courseFactory } from './course.factory'; -import { schoolExternalToolFactory } from './school-external-tool.factory'; - -export const contextExternalToolFactory = BaseFactory.define( - ContextExternalTool, - () => { - return { - contextId: courseFactory.buildWithId().id, - contextType: ContextExternalToolType.COURSE, - displayName: 'My Course Tool 1', - schoolTool: schoolExternalToolFactory.buildWithId(), - parameters: [new CustomParameterEntry({ name: 'mockParamater', value: 'mockValue' })], - toolVersion: 1, - }; - } -); diff --git a/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.spec.ts b/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.spec.ts new file mode 100644 index 00000000000..189e552070c --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.spec.ts @@ -0,0 +1,24 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { MethodNotAllowedException } from '@nestjs/common'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { DomainObjectFactory } from './domain-object.factory'; + +class TestClass extends DomainObject {} + +const testFactory = DomainObjectFactory.define(TestClass, () => { + return { + id: new ObjectId().toHexString(), + }; +}); + +describe('DomainObjectFactory', () => { + describe('buildWithId', () => { + it('should throw not allowed', () => { + const id = 'id'; + + const func = () => testFactory.buildWithId(undefined, id); + + expect(func).toThrow(MethodNotAllowedException); + }); + }); +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.ts b/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.ts new file mode 100644 index 00000000000..8d6d69f9b23 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/domain-object.factory.ts @@ -0,0 +1,19 @@ +import { MethodNotAllowedException } from '@nestjs/common'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { BuildOptions, DeepPartial } from 'fishery'; +import { BaseFactory } from '../base.factory'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class DomainObjectFactory< + T extends DomainObject, + U extends AuthorizableObject = T extends DomainObject ? X : never, + I = any, + C = U +> extends BaseFactory { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override buildWithId(params?: DeepPartial, id?: string, options: BuildOptions = {}): T { + throw new MethodNotAllowedException( + 'Domain Objects are always generated with an id. Use .build({ id: ... }) to set an id.' + ); + } +} diff --git a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts new file mode 100644 index 00000000000..a65d5141b61 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts @@ -0,0 +1,25 @@ +import { ExternalSource } from '@shared/domain'; +import { Group, GroupProps, GroupTypes } from '@src/modules/group/domain'; +import { ObjectId } from 'bson'; +import { DomainObjectFactory } from '../domain-object.factory'; + +export const groupFactory = DomainObjectFactory.define(Group, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + name: `Group ${sequence}`, + type: GroupTypes.CLASS, + users: [ + { + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }, + ], + validFrom: new Date(2023, 1), + validUntil: new Date(2023, 6), + organizationId: new ObjectId().toHexString(), + externalSource: new ExternalSource({ + externalId: `externalId-${sequence}`, + systemId: new ObjectId().toHexString(), + }), + }; +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/groups/index.ts b/apps/server/src/shared/testing/factory/domainobject/groups/index.ts new file mode 100644 index 00000000000..3384f6c5ed1 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/groups/index.ts @@ -0,0 +1 @@ +export * from './group.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 3ebd47e3ec7..c1d814b9ca7 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -1,6 +1,8 @@ export * from './board'; export * from './tool'; +export * from './groups'; export * from './do-base.factory'; +export * from './domain-object.factory'; export * from './school.factory'; export * from './user-login-migration-do.factory'; export * from './lti-tool.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/pseudonym.factory.ts b/apps/server/src/shared/testing/factory/domainobject/pseudonym.factory.ts index c3cc2c803a2..08635e26e3c 100644 --- a/apps/server/src/shared/testing/factory/domainobject/pseudonym.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/pseudonym.factory.ts @@ -1,8 +1,8 @@ -import { Pseudonym, PseudonymProps } from '@shared/domain'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DoBaseFactory } from './do-base.factory'; +import { Pseudonym, PseudonymProps } from '@shared/domain'; +import { DomainObjectFactory } from './domain-object.factory'; -export const pseudonymFactory = DoBaseFactory.define(Pseudonym, ({ sequence }) => { +export const pseudonymFactory = DomainObjectFactory.define(Pseudonym, ({ sequence }) => { return { id: new ObjectId().toHexString(), pseudonym: `pseudonym${sequence}`, diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/context-external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/context-external-tool.factory.ts index 44bf3c23a7b..015713dd997 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/context-external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/context-external-tool.factory.ts @@ -1,31 +1,25 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { - ContextExternalToolDO, - ContextExternalToolProps, - CustomParameterEntryDO, -} from '@shared/domain/domainobject/tool'; +import { CustomParameterEntry } from '@src/modules/tool/common/domain'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextExternalToolProps } from '@src/modules/tool/context-external-tool/domain'; import { DeepPartial } from 'fishery'; -import { ToolContextType } from '@src/modules/tool/common/interface'; import { DoBaseFactory } from '../do-base.factory'; -class ContextExternalToolDOFactory extends DoBaseFactory { +class ContextExternalToolFactory extends DoBaseFactory { withSchoolExternalToolRef(schoolToolId: string, schoolId?: string | undefined): this { - const params: DeepPartial = { + const params: DeepPartial = { schoolToolRef: { schoolToolId, schoolId }, }; return this.params(params); } } -export const contextExternalToolDOFactory = ContextExternalToolDOFactory.define( - ContextExternalToolDO, - ({ sequence }) => { - return { - schoolToolRef: { schoolToolId: `schoolToolId-${sequence}`, schoolId: 'schoolId' }, - contextRef: { id: new ObjectId().toHexString(), type: ToolContextType.COURSE }, - displayName: 'My Course Tool 1', - parameters: [new CustomParameterEntryDO({ name: 'param', value: 'value' })], - toolVersion: 1, - }; - } -); +export const contextExternalToolFactory = ContextExternalToolFactory.define(ContextExternalTool, ({ sequence }) => { + return { + schoolToolRef: { schoolToolId: `schoolToolId-${sequence}`, schoolId: 'schoolId' }, + contextRef: { id: new ObjectId().toHexString(), type: ToolContextType.COURSE }, + displayName: 'My Course Tool 1', + parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], + toolVersion: 1, + }; +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts index 071f38208e3..cc33b3d63b9 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts @@ -1,34 +1,33 @@ -import { DeepPartial } from 'fishery'; +import { CustomParameter } from '@src/modules/tool/common/domain'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType, LtiMessageType, LtiPrivacyPermission, + TokenEndpointAuthMethod, ToolConfigType, - BasicToolConfigDO, - CustomParameterDO, - ExternalToolDO, - Lti11ToolConfigDO, - Oauth2ToolConfigDO, +} from '@src/modules/tool/common/enum'; +import { + BasicToolConfig, + ExternalTool, ExternalToolProps, -} from '@shared/domain'; -import { TokenEndpointAuthMethod } from '@src/modules/tool/common/interface'; + Lti11ToolConfig, + Oauth2ToolConfig, +} from '@src/modules/tool/external-tool/domain'; +import { DeepPartial } from 'fishery'; import { DoBaseFactory } from '../do-base.factory'; -export const basicToolConfigDOFactory = DoBaseFactory.define( - BasicToolConfigDO, - () => { - return { - type: ToolConfigType.BASIC, - baseUrl: 'https://www.basic-baseUrl.com/', - }; - } -); +export const basicToolConfigFactory = DoBaseFactory.define(BasicToolConfig, () => { + return { + type: ToolConfigType.BASIC, + baseUrl: 'https://www.basic-baseUrl.com/', + }; +}); -class Oauth2ToolConfigDOFactory extends DoBaseFactory { - withExternalData(oauth2Params?: DeepPartial): this { - const params: DeepPartial = { +class Oauth2ToolConfigFactory extends DoBaseFactory { + withExternalData(oauth2Params?: DeepPartial): this { + const params: DeepPartial = { clientSecret: 'clientSecret', scope: 'offline openid', frontchannelLogoutUri: 'https://www.frontchannel.com/', @@ -40,7 +39,7 @@ class Oauth2ToolConfigDOFactory extends DoBaseFactory { +export const oauth2ToolConfigFactory = Oauth2ToolConfigFactory.define(Oauth2ToolConfig, () => { return { type: ToolConfigType.OAUTH2, baseUrl: 'https://www.oauth2-baseUrl.com/', @@ -49,63 +48,76 @@ export const oauth2ToolConfigDOFactory = Oauth2ToolConfigDOFactory.define(Oauth2 }; }); -export const lti11ToolConfigDOFactory = DoBaseFactory.define( - Lti11ToolConfigDO, - () => { - return { - type: ToolConfigType.LTI11, - baseUrl: 'https://www.lti11-baseUrl.com/', - key: 'key', - secret: 'secret', - privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - resource_link_id: 'linkId', - }; +export const lti11ToolConfigFactory = DoBaseFactory.define(Lti11ToolConfig, () => { + return { + type: ToolConfigType.LTI11, + baseUrl: 'https://www.lti11-baseUrl.com/', + key: 'key', + secret: 'secret', + privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + resource_link_id: 'linkId', + launch_presentation_locale: 'de-DE', + }; +}); + +class CustomParameterFactory extends DoBaseFactory { + buildListWithEachType(params?: DeepPartial): CustomParameter[] { + const globalParameter = this.build({ ...params, scope: CustomParameterScope.GLOBAL }); + const schoolParameter = this.build({ ...params, scope: CustomParameterScope.SCHOOL }); + const contextParameter = this.build({ ...params, scope: CustomParameterScope.CONTEXT }); + + return [globalParameter, schoolParameter, contextParameter]; } -); +} + +export const customParameterFactory = CustomParameterFactory.define(CustomParameter, ({ sequence }) => { + return { + name: `custom-parameter-${sequence}`, + displayName: 'User Friendly Name', + type: CustomParameterType.STRING, + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.BODY, + isOptional: false, + }; +}); -export const customParameterDOFactory = DoBaseFactory.define( - CustomParameterDO, - ({ sequence }) => { - return { - name: `custom-parameter-${sequence}`, - displayName: 'User Friendly Name', - type: CustomParameterType.STRING, - scope: CustomParameterScope.GLOBAL, - location: CustomParameterLocation.BODY, - isOptional: false, +class ExternalToolFactory extends DoBaseFactory { + withOauth2Config(customParam?: DeepPartial): this { + const params: DeepPartial = { + config: oauth2ToolConfigFactory.build(customParam), }; + return this.params(params); } -); -class ExternalToolDOFactory extends DoBaseFactory { - withOauth2Config(customParam?: DeepPartial): this { - const params: DeepPartial = { - config: oauth2ToolConfigDOFactory.build(customParam), + withLti11Config(customParam?: DeepPartial): this { + const params: DeepPartial = { + config: lti11ToolConfigFactory.build(customParam), }; return this.params(params); } - withLti11Config(customParam?: DeepPartial): this { - const params: DeepPartial = { - config: lti11ToolConfigDOFactory.build(customParam), + withCustomParameters(number: number, customParam?: DeepPartial): this { + const params: DeepPartial = { + parameters: customParameterFactory.buildList(number, customParam), }; return this.params(params); } - withCustomParameters(number: number, customParam?: DeepPartial): this { - const params: DeepPartial = { - parameters: customParameterDOFactory.buildList(number, customParam), + withBase64Logo(): this { + const params: DeepPartial = { + logo: 'iVBORw0KGgoAAAANSUhEUgAAAfQAAADICAYAAAAeGRPoAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyNpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQwIDc5LjE2MDQ1MSwgMjAxNy8wNS8wNi0wMTowODoyMSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjQ2MUQ2Q0Y5RTQxMTExRTdBMTg3QkQ2MDVGMUFEMUIwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjQ2MUQ2Q0ZBRTQxMTExRTdBMTg3QkQ2MDVGMUFEMUIwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NDYxRDZDRjdFNDExMTFFN0ExODdCRDYwNUYxQUQxQjAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NDYxRDZDRjhFNDExMTFFN0ExODdCRDYwNUYxQUQxQjAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz45EjsrAAALfUlEQVR42uzdgXWjOAIGYHLvGsiV4CnBU4JTgqeEpIS4hKSEpIS4BLsEu4RJCeMScmhGzPplkyCMAGO+7z3ezs3tYsuS+BEIcfX29lYAAOP2Hz8BAAh0AECgAwACHQAQ6AAg0AEAgQ4ACHQAQKADgEAHAAQ6ACDQAQCBDgACHQAQ6ACAQAcABDoACHQAQKADAAIdABDoACDQAQCBDgAIdABAoAOAQAcABDoAINABAIEOAAIdABDoAIBABwAEOgAIdABAoAMAAh0AEOgAINABAIEOAAh0AECgA4BABwAEOgAg0AEAgQ4ACHQAEOgAgEAHAAQ6ACDQAUCgAwACHQAQ6ACAQAcAgQ4ACHQAQKADAAIdAAQ6ACDQAQCBDgAIdAAQ6ACAQAcABDoAINABQKADAAIdABDoAIBABwCBDgAIdABAoAMAAh0ABDoAINABgN79109AbldXV9flPxblNov/DOblFv7+UG77+HfVn39vb29vB78emdpg1fauP2iDwWvcgm3883aMbbAs6/yorPP414ujf+W4z+2r/12WdasOL6zdl4Ufa4fdvGu0gyp/x6sTyjD0jx8a/03GOgn1cVtuyxN3EQ4267CV3+t16u2jhz701lfb6DEAlnGbt2yDz+ccDDHEq7LOTtzNIZY11PVaHV6AEOhj3ErhgP12LtuJZRj6e28y1cW8g/p4CgeqKbePHvpQ522jp3LMYnvJWWe/2rbBjsq66Kht/wwn4+pw3Jt76LQ9o76NB5jco+Gw35/l/p/iJXx43/auy+2+CqPMu7+O+9zFzziHsj511Nf+Bmr5GT/jlTZ1OEICnbZh/lT8c0+rC1WwL/3ivLvkvCu3h44/KrTth/LzdvFy8BBlXXQUeJ8F+6b8zIeuT6SnVIcCnXM/oC5jmPchdMiXqZxlk3QiuStOv3d8inkc6c0HKOum45Pmj9zHYJ+pQ4HOZR9Qr08I8zBRZRu3U4RJcs9+fWHe44nkRyeWu/gd+ijr04BlrRzU4Xh4bI1T3CaMGMKB4LH4M4N2/0Gnrh5JqWbr1u3vzmNtwrxhEFSzuEP7ez1+TCu2v9lR+2syagv3mvcfteuMZb0vml1ifz0q6/74KZF3Za3Km/Lb/cjd56ZUh4OYyuy/1NnPZhknfe9fNd/9JQR0g/1Vk1d+frK/hym2D+3vX7O7G83YbtgGm86yDn1g1lFZlw3Lumy4/9Df7mv68VwdjrBPC3SBnrlT7lru//2BZtekUwv0y2t/MYB+JR6kH9q0lzjK2yV+1q6jx7dSy3qf4Xe9/2C/t+rQY2tMQ91lrceWV4zCf/8tXmZzqZ2iSH+SIrSVVZv2Ei/BhgV1UuZrzDuYqJlS1upyeNu+doj7+F78s+LaY/l3z+pwnAQ6WQM9x4pT8UDzI3TKi7vHRdN7rovEe753uYIotr+7xEC4zzUTPD45kvIM+E3Old1iH/sew3ylDgU609Hb4zPnvtY0vUgZPd11MaqMgbBP6A+5RngPiWXdd1DWQxdhPsE6FOhc1IjKqm7kHNnVjVjXHV0iroQrRXWXf2/btvtY1tnAZVWHAp2JqesYVnQjl5S2tOryC8THv1LuVbd9rvk2od+t1OFZ16FAZ3TqLl89XPJKTPQ2srtOCIPHtm/lSwyEEAZ1n7PsuKzPfZRVHQp0pqWuU4ROvLnUlZjoTfUe7C9DrsfvU/dZ8xYTq5YZPl8dDluHAp1RSpmo9ntp2Pjmpnv31TlB3VWefc8j1nWG7/yZ2ZmVVR0KdKYgPh+aelYdDlRh5u6vMtQ3MdxdjidHGKx7bvchePYJ7X30ZVWHAp38FmX4vXWwbTJ8t3A/qunCD4sY7uHFCCHgX2LAz1Q1n7SXL0d3A3ynbcvvPKayqsMR8nIWTjrTLYM4zEw99Y1J1WSZsIVJdNWLJdYWkiHREJegD2Mqa3ineZHpEnLZL2/UoUDnckP9uTxgFEWe1yCGUXpY2CGM2EOgP4/teVvySbktM9A95bqTzcUJZV10WNb5UCPOKdXhOXHJnVahXqQt2tD0IFRNqPNM+zSZRKkOEegMEOrhUnl4mcoqc7CHUXu4z/5kljyAQKefUD8cvSUtBHvOS2nhefaNUGcEvBVQHQp0LivYyy0E+++3NxV5ZrKGy/AvfuHJtKPatQ4Gevyx9nnxCyqrOhToZLQtO8VVB9tNTx16H99rHIL9f8Wfe+1tAn5xSe8tpvMDcxeuJ1RWdSjQ4dOR+/oo4MMIPrzWsOnCEladm9AJbc3/P8TobtHyO5/6381O7Hc3qSf6RTcvSJlSHQp0Jhvwr2GGfLn9iKP31Al1KS974DKc1Ys04onkouV3HkVZ1aFAhzaj92pCXcqz55aOnYbaJTp7vgebEj7bjso61peGTKkOBTq8C/a7hFC3VOw0pNyO6fONfnWftY3vOTjF9szKqg4FOmRRdy9v4SeaxgleQiDc9jFyja8C7uxFI4kvDbkd2yh9SnUo0OHzg8DWL0HiAfapyy8Q77vWPV1xKNqHQd2VqfA9HtThWdehQGecQieJZ73Q1cldOMDWTVLq+nHGEKJ1I8jHtpdq4zLKdftYjq3PTakOBTpjFl7D+hTf6JTbV4+meRvbtKQ8TvXQRdCFZYeL+vuuhyJtMmeKx8SyztXh2dahQGd0o/PQSaqDSng2fJPrPljcz1cHrFc1MLlResotmKeco7zEIMg6sotPe9S173Cyu+ngxUVzdSjQmV6Y337QScJEtV2mzlh3P80IfXruirR1CsIo76XN4kPhhDKcoCYGwTaGcO6y1gnle8nR38JoP5Z3qQ4FOtMK88UXgXsdO2N47elt0w4Z78m/FPWz2NdqYnKj9DBqTV3JLARTaIONVhWMIRACclekPUkRwulHB2UNI9nUgPnb307py3EEm1pedTiGY3T5Q08tlDZfVXZcBrGv7zL4j59a3njfblM0Wwv5OY6ow7ru+y/2u4xn03X73na9Fv05tY9Lbn+n/I7xYN10zsa6aoOxHR6qE8jiz2XmamsyQg37uPmsTWeqm5cTvlNV1tfjl6MclbW6nbUoGq7nkKvdT6kOBbpAP+dAv46B3uZe26H455L5rGi+SMz3rjugQD/fQI/fOfW+aFd6CYJM/S2XcI95lbFsk6jDIbjkTuoB+BBfrNLmflO1lnLjEUJpdYkdkMbtMNyLXQ308b0FQRyFhqtRQ86+/n1JOmeYT6kOBTpjOKCu4oGmz9nmz5c0cYXWbfAxtsE+ZyaHS9jf+gyCo+WQhwi/dSzvWh0KdC77gBo6xvci/S1pbaziQQ3et8HUF/q0HdHdxVeRHgYqaxV+fQTRaxzB/ui6vFOqQ4HOuR9Qj9+StupgxL6PBxYjc+pGsDdF/uWCD7Fdf4uruA1+AhNved0V3VwdC79fCPFvxxPq1OG4mBT37wZmUtzp5VnG3zb889TnSMMlvnVXl/rG1D4uuf118TvGRYluY/ubtWh/29gGD2dcdzn62j6W9Tk+VnYO5ZpMHQp0xhQW1aMk1+8Csvrz69FIYxv/vJ1aB6TTYKgmX87ftb3j9lc9eTHa9hf7WlXW2Qdl3cdyjqqsU6pDgQ4A/OUeOgAIdABAoAMAAh0AEOgAINABAIEOAAh0AECgA4BABwAEOgAg0AEAgQ4AAh0AEOgAgEAHAAQ6AAh0AECgAwACHQAQ6AAg0AEAgQ4ACHQAQKADgEAHAAQ6ACDQAQCBDgACHQAQ6ACAQAcABDoACHQAQKADAAIdABDoACDQAQCBDgAIdABAoAOAQAcABDoAINABAIEOAAh0ABDoAIBABwAEOgAg0AFAoAMAAh0AEOgAgEAHAIEOAAh0AECgAwACHQAEOgAg0AEAgQ4ACHQAEOgAgEAHAAQ6ACDQAUCgAwACHQAQ6ACAQAcAgQ4ACHQAQKADAAIdAAQ6ACDQAYD+/V+AAQADXuXS75wQpQAAAABJRU5ErkJggg==', }; + return this.params(params); } } -export const externalToolDOFactory = ExternalToolDOFactory.define(ExternalToolDO, ({ sequence }) => { +export const externalToolFactory = ExternalToolFactory.define(ExternalTool, ({ sequence }) => { return { name: `external-tool-${sequence}`, url: 'https://url.com/', - config: basicToolConfigDOFactory.build(), + config: basicToolConfigFactory.build(), logoUrl: 'https://logo.com/', isHidden: false, openNewTab: false, diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool.factory.ts index 7a824b70064..08c52e487cc 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool.factory.ts @@ -1,28 +1,24 @@ +import { CustomParameterEntry, ToolConfigurationStatus } from '@src/modules/tool/common/domain'; +import { SchoolExternalTool, SchoolExternalToolProps } from '@src/modules/tool/school-external-tool/domain'; import { DeepPartial } from 'fishery'; -import { - CustomParameterEntryDO, - SchoolExternalToolDO, - SchoolExternalToolProps, - ToolConfigurationStatus, -} from '@shared/domain'; import { DoBaseFactory } from '../do-base.factory'; -class SchoolExternalToolDOFactory extends DoBaseFactory { +class SchoolExternalToolFactory extends DoBaseFactory { withSchoolId(schoolId: string): this { - const params: DeepPartial = { + const params: DeepPartial = { schoolId, }; return this.params(params); } } -export const schoolExternalToolDOFactory = SchoolExternalToolDOFactory.define(SchoolExternalToolDO, ({ sequence }) => { +export const schoolExternalToolFactory = SchoolExternalToolFactory.define(SchoolExternalTool, ({ sequence }) => { return { name: `schoolExternal-${sequence}`, schoolId: `schoolId-${sequence}`, toolVersion: 1, parameters: [ - new CustomParameterEntryDO({ + new CustomParameterEntry({ name: 'name', value: 'value', }), diff --git a/apps/server/src/shared/testing/factory/external-group-dto.factory.ts b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts new file mode 100644 index 00000000000..241b1fb45bd --- /dev/null +++ b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts @@ -0,0 +1,29 @@ +import { RoleName } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { ExternalGroupDto } from '@src/modules/provisioning/dto'; +import { GroupTypes } from '@src/modules/group'; +import { BaseFactory } from './base.factory'; + +export const externalGroupDtoFactory = BaseFactory.define( + ExternalGroupDto, + ({ sequence }) => { + return { + externalId: new ObjectId().toHexString(), + name: `Group ${sequence}`, + type: GroupTypes.CLASS, + users: [ + { + externalUserId: new ObjectId().toHexString(), + roleName: RoleName.TEACHER, + }, + { + externalUserId: new ObjectId().toHexString(), + roleName: RoleName.STUDENT, + }, + ], + from: new Date(2023, 1), + until: new Date(2023, 6), + externalOrganizationId: new ObjectId().toHexString(), + }; + } +); diff --git a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts new file mode 100644 index 00000000000..ed47f93b28b --- /dev/null +++ b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts @@ -0,0 +1,111 @@ +import { + CustomParameterLocation, + CustomParameterScope, + CustomParameterType, + LtiMessageType, + LtiPrivacyPermission, + ToolConfigType, +} from '@src/modules/tool/common/enum'; +import { + BasicToolConfigEntity, + CustomParameterEntity, + ExternalToolEntity, + IExternalToolProperties, + Lti11ToolConfigEntity, + Oauth2ToolConfigEntity, +} from '@src/modules/tool/external-tool/entity'; +import { DeepPartial } from 'fishery'; +import { BaseFactory } from './base.factory'; + +export class ExternalToolEntityFactory extends BaseFactory { + withName(name: string): this { + const params: DeepPartial = { + name, + }; + return this.params(params); + } + + withBasicConfig(): this { + const params: DeepPartial = { + config: new BasicToolConfigEntity({ + type: ToolConfigType.BASIC, + baseUrl: 'mockBaseUrl', + }), + }; + return this.params(params); + } + + withOauth2Config(clientId: string): this { + const params: DeepPartial = { + config: new Oauth2ToolConfigEntity({ + type: ToolConfigType.OAUTH2, + baseUrl: 'mockBaseUrl', + clientId, + skipConsent: false, + }), + }; + return this.params(params); + } + + withLti11Config(): this { + const params: DeepPartial = { + config: new Lti11ToolConfigEntity({ + type: ToolConfigType.BASIC, + baseUrl: 'mockBaseUrl', + key: 'key', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + resource_link_id: 'resource_link_id', + secret: 'secret', + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: 'de-DE', + }), + }; + return this.params(params); + } + + withBase64Logo(): this { + const params: DeepPartial = { + logoBase64: + 'iVBORw0KGgoAAAANSUhEUgAAAfQAAADICAYAAAAeGRPoAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyNpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQwIDc5LjE2MDQ1MSwgMjAxNy8wNS8wNi0wMTowODoyMSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjQ2MUQ2Q0Y5RTQxMTExRTdBMTg3QkQ2MDVGMUFEMUIwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjQ2MUQ2Q0ZBRTQxMTExRTdBMTg3QkQ2MDVGMUFEMUIwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NDYxRDZDRjdFNDExMTFFN0ExODdCRDYwNUYxQUQxQjAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NDYxRDZDRjhFNDExMTFFN0ExODdCRDYwNUYxQUQxQjAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz45EjsrAAALfUlEQVR42uzdgXWjOAIGYHLvGsiV4CnBU4JTgqeEpIS4hKSEpIS4BLsEu4RJCeMScmhGzPplkyCMAGO+7z3ezs3tYsuS+BEIcfX29lYAAOP2Hz8BAAh0AECgAwACHQAQ6AAg0AEAgQ4ACHQAQKADgEAHAAQ6ACDQAQCBDgACHQAQ6ACAQAcABDoACHQAQKADAAIdABDoACDQAQCBDgAIdABAoAOAQAcABDoAINABAIEOAAIdABDoAIBABwAEOgAIdABAoAMAAh0AEOgAINABAIEOAAh0AECgA4BABwAEOgAg0AEAgQ4ACHQAEOgAgEAHAAQ6ACDQAUCgAwACHQAQ6ACAQAcAgQ4ACHQAQKADAAIdAAQ6ACDQAQCBDgAIdAAQ6ACAQAcABDoAINABQKADAAIdABDoAIBABwCBDgAIdABAoAMAAh0ABDoAINABgN79109AbldXV9flPxblNov/DOblFv7+UG77+HfVn39vb29vB78emdpg1fauP2iDwWvcgm3883aMbbAs6/yorPP414ujf+W4z+2r/12WdasOL6zdl4Ufa4fdvGu0gyp/x6sTyjD0jx8a/03GOgn1cVtuyxN3EQ4267CV3+t16u2jhz701lfb6DEAlnGbt2yDz+ccDDHEq7LOTtzNIZY11PVaHV6AEOhj3ErhgP12LtuJZRj6e28y1cW8g/p4CgeqKbePHvpQ522jp3LMYnvJWWe/2rbBjsq66Kht/wwn4+pw3Jt76LQ9o76NB5jco+Gw35/l/p/iJXx43/auy+2+CqPMu7+O+9zFzziHsj511Nf+Bmr5GT/jlTZ1OEICnbZh/lT8c0+rC1WwL/3ivLvkvCu3h44/KrTth/LzdvFy8BBlXXQUeJ8F+6b8zIeuT6SnVIcCnXM/oC5jmPchdMiXqZxlk3QiuStOv3d8inkc6c0HKOum45Pmj9zHYJ+pQ4HOZR9Qr08I8zBRZRu3U4RJcs9+fWHe44nkRyeWu/gd+ijr04BlrRzU4Xh4bI1T3CaMGMKB4LH4M4N2/0Gnrh5JqWbr1u3vzmNtwrxhEFSzuEP7ez1+TCu2v9lR+2syagv3mvcfteuMZb0vml1ifz0q6/74KZF3Za3Km/Lb/cjd56ZUh4OYyuy/1NnPZhknfe9fNd/9JQR0g/1Vk1d+frK/hym2D+3vX7O7G83YbtgGm86yDn1g1lFZlw3Lumy4/9Df7mv68VwdjrBPC3SBnrlT7lru//2BZtekUwv0y2t/MYB+JR6kH9q0lzjK2yV+1q6jx7dSy3qf4Xe9/2C/t+rQY2tMQ91lrceWV4zCf/8tXmZzqZ2iSH+SIrSVVZv2Ei/BhgV1UuZrzDuYqJlS1upyeNu+doj7+F78s+LaY/l3z+pwnAQ6WQM9x4pT8UDzI3TKi7vHRdN7rovEe753uYIotr+7xEC4zzUTPD45kvIM+E3Old1iH/sew3ylDgU609Hb4zPnvtY0vUgZPd11MaqMgbBP6A+5RngPiWXdd1DWQxdhPsE6FOhc1IjKqm7kHNnVjVjXHV0iroQrRXWXf2/btvtY1tnAZVWHAp2JqesYVnQjl5S2tOryC8THv1LuVbd9rvk2od+t1OFZ16FAZ3TqLl89XPJKTPQ2srtOCIPHtm/lSwyEEAZ1n7PsuKzPfZRVHQp0pqWuU4ROvLnUlZjoTfUe7C9DrsfvU/dZ8xYTq5YZPl8dDluHAp1RSpmo9ntp2Pjmpnv31TlB3VWefc8j1nWG7/yZ2ZmVVR0KdKYgPh+aelYdDlRh5u6vMtQ3MdxdjidHGKx7bvchePYJ7X30ZVWHAp38FmX4vXWwbTJ8t3A/qunCD4sY7uHFCCHgX2LAz1Q1n7SXL0d3A3ynbcvvPKayqsMR8nIWTjrTLYM4zEw99Y1J1WSZsIVJdNWLJdYWkiHREJegD2Mqa3ineZHpEnLZL2/UoUDnckP9uTxgFEWe1yCGUXpY2CGM2EOgP4/teVvySbktM9A95bqTzcUJZV10WNb5UCPOKdXhOXHJnVahXqQt2tD0IFRNqPNM+zSZRKkOEegMEOrhUnl4mcoqc7CHUXu4z/5kljyAQKefUD8cvSUtBHvOS2nhefaNUGcEvBVQHQp0LivYyy0E+++3NxV5ZrKGy/AvfuHJtKPatQ4Gevyx9nnxCyqrOhToZLQtO8VVB9tNTx16H99rHIL9f8Wfe+1tAn5xSe8tpvMDcxeuJ1RWdSjQ4dOR+/oo4MMIPrzWsOnCEladm9AJbc3/P8TobtHyO5/6381O7Hc3qSf6RTcvSJlSHQp0Jhvwr2GGfLn9iKP31Al1KS974DKc1Ys04onkouV3HkVZ1aFAhzaj92pCXcqz55aOnYbaJTp7vgebEj7bjso61peGTKkOBTq8C/a7hFC3VOw0pNyO6fONfnWftY3vOTjF9szKqg4FOmRRdy9v4SeaxgleQiDc9jFyja8C7uxFI4kvDbkd2yh9SnUo0OHzg8DWL0HiAfapyy8Q77vWPV1xKNqHQd2VqfA9HtThWdehQGecQieJZ73Q1cldOMDWTVLq+nHGEKJ1I8jHtpdq4zLKdftYjq3PTakOBTpjFl7D+hTf6JTbV4+meRvbtKQ8TvXQRdCFZYeL+vuuhyJtMmeKx8SyztXh2dahQGd0o/PQSaqDSng2fJPrPljcz1cHrFc1MLlResotmKeco7zEIMg6sotPe9S173Cyu+ngxUVzdSjQmV6Y337QScJEtV2mzlh3P80IfXruirR1CsIo76XN4kPhhDKcoCYGwTaGcO6y1gnle8nR38JoP5Z3qQ4FOtMK88UXgXsdO2N47elt0w4Z78m/FPWz2NdqYnKj9DBqTV3JLARTaIONVhWMIRACclekPUkRwulHB2UNI9nUgPnb307py3EEm1pedTiGY3T5Q08tlDZfVXZcBrGv7zL4j59a3njfblM0Wwv5OY6ow7ru+y/2u4xn03X73na9Fv05tY9Lbn+n/I7xYN10zsa6aoOxHR6qE8jiz2XmamsyQg37uPmsTWeqm5cTvlNV1tfjl6MclbW6nbUoGq7nkKvdT6kOBbpAP+dAv46B3uZe26H455L5rGi+SMz3rjugQD/fQI/fOfW+aFd6CYJM/S2XcI95lbFsk6jDIbjkTuoB+BBfrNLmflO1lnLjEUJpdYkdkMbtMNyLXQ308b0FQRyFhqtRQ86+/n1JOmeYT6kOBTpjOKCu4oGmz9nmz5c0cYXWbfAxtsE+ZyaHS9jf+gyCo+WQhwi/dSzvWh0KdC77gBo6xvci/S1pbaziQQ3et8HUF/q0HdHdxVeRHgYqaxV+fQTRaxzB/ui6vFOqQ4HOuR9Qj9+StupgxL6PBxYjc+pGsDdF/uWCD7Fdf4uruA1+AhNved0V3VwdC79fCPFvxxPq1OG4mBT37wZmUtzp5VnG3zb889TnSMMlvnVXl/rG1D4uuf118TvGRYluY/ubtWh/29gGD2dcdzn62j6W9Tk+VnYO5ZpMHQp0xhQW1aMk1+8Csvrz69FIYxv/vJ1aB6TTYKgmX87ftb3j9lc9eTHa9hf7WlXW2Qdl3cdyjqqsU6pDgQ4A/OUeOgAIdABAoAMAAh0AEOgAINABAIEOAAh0AECgA4BABwAEOgAg0AEAgQ4AAh0AEOgAgEAHAAQ6AAh0AECgAwACHQAQ6AAg0AEAgQ4ACHQAQKADgEAHAAQ6ACDQAQCBDgACHQAQ6ACAQAcABDoACHQAQKADAAIdABDoACDQAQCBDgAIdABAoAOAQAcABDoAINABAIEOAAh0ABDoAIBABwAEOgAg0AFAoAMAAh0AEOgAgEAHAIEOAAh0AECgAwACHQAEOgAg0AEAgQ4ACHQAEOgAgEAHAAQ6ACDQAUCgAwACHQAQ6ACAQAcAgQ4ACHQAQKADAAIdAAQ6ACDQAYD+/V+AAQADXuXS75wQpQAAAABJRU5ErkJggg==', + }; + + return this.params(params); + } +} + +export const customParameterEntityFactory = BaseFactory.define( + CustomParameterEntity, + ({ sequence }) => { + return { + name: `name${sequence}`, + displayName: `User Friendly Name ${sequence}`, + description: 'This is a mock parameter.', + default: 'default', + location: CustomParameterLocation.PATH, + regex: 'regex', + regexComment: 'mockComment', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }; + } +); + +export const externalToolEntityFactory = ExternalToolEntityFactory.define( + ExternalToolEntity, + ({ sequence }): IExternalToolProperties => { + return { + name: `external-tool-${sequence}`, + url: '', + logoUrl: 'https://logourl.com', + config: new BasicToolConfigEntity({ + type: ToolConfigType.BASIC, + baseUrl: 'mockBaseUrl', + }), + parameters: [customParameterEntityFactory.build()], + isHidden: false, + openNewTab: true, + version: 1, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/external-tool.factory.ts b/apps/server/src/shared/testing/factory/external-tool.factory.ts deleted file mode 100644 index adf19695bb4..00000000000 --- a/apps/server/src/shared/testing/factory/external-tool.factory.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - BasicToolConfig, - CustomParameter, - CustomParameterLocation, - CustomParameterScope, - CustomParameterType, - ExternalTool, - IExternalToolProperties, - Lti11ToolConfig, - LtiMessageType, - LtiPrivacyPermission, - Oauth2ToolConfig, - ToolConfigType, -} from '@shared/domain'; -import { DeepPartial } from 'fishery'; -import { BaseFactory } from './base.factory'; - -export class ExternalToolFactory extends BaseFactory { - withName(name: string): this { - const params: DeepPartial = { - name, - }; - return this.params(params); - } - - withBasicConfig(): this { - const params: DeepPartial = { - config: new BasicToolConfig({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), - }; - return this.params(params); - } - - withOauth2Config(clientId: string): this { - const params: DeepPartial = { - config: new Oauth2ToolConfig({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - clientId, - skipConsent: false, - }), - }; - return this.params(params); - } - - withLti11Config(): this { - const params: DeepPartial = { - config: new Lti11ToolConfig({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - key: 'key', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - resource_link_id: 'resource_link_id', - secret: 'secret', - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - }), - }; - return this.params(params); - } -} - -export const externalToolFactory = ExternalToolFactory.define(ExternalTool, ({ sequence }): IExternalToolProperties => { - return { - name: `external-tool-${sequence}`, - url: '', - logoUrl: '', - config: new BasicToolConfig({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), - parameters: [ - new CustomParameter({ - name: 'name', - displayName: 'User Friendly Name', - description: 'This is a mock parameter.', - default: 'default', - location: CustomParameterLocation.PATH, - regex: 'regex', - regexComment: 'mockComment', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - isOptional: false, - }), - ], - isHidden: false, - openNewTab: true, - version: 1, - }; -}); diff --git a/apps/server/src/shared/testing/factory/group-entity.factory.ts b/apps/server/src/shared/testing/factory/group-entity.factory.ts new file mode 100644 index 00000000000..591b7c37d41 --- /dev/null +++ b/apps/server/src/shared/testing/factory/group-entity.factory.ts @@ -0,0 +1,33 @@ +import { ExternalSourceEntity, RoleName } from '@shared/domain'; +import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupValidPeriodEntity } from '@src/modules/group/entity'; +import { BaseFactory } from './base.factory'; +import { roleFactory } from './role.factory'; +import { schoolFactory } from './school.factory'; +import { systemFactory } from './system.factory'; +import { userFactory } from './user.factory'; + +export const groupEntityFactory = BaseFactory.define(GroupEntity, ({ sequence }) => { + return { + name: `Group ${sequence}`, + type: GroupEntityTypes.CLASS, + users: [ + { + user: userFactory.buildWithId(), + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }, + { + user: userFactory.buildWithId(), + role: roleFactory.buildWithId({ name: RoleName.TEACHER }), + }, + ], + validPeriod: new GroupValidPeriodEntity({ + from: new Date(2023, 1), + until: new Date(2023, 6), + }), + organization: schoolFactory.buildWithId(), + externalSource: new ExternalSourceEntity({ + externalId: `externalId-${sequence}`, + system: systemFactory.buildWithId(), + }), + }; +}); diff --git a/apps/server/src/shared/testing/factory/h5p-content.factory.ts b/apps/server/src/shared/testing/factory/h5p-content.factory.ts index c440f1a24fa..4d07c369cd5 100644 --- a/apps/server/src/shared/testing/factory/h5p-content.factory.ts +++ b/apps/server/src/shared/testing/factory/h5p-content.factory.ts @@ -1,10 +1,20 @@ -import { ContentMetadata, H5PContent } from '@src/modules/h5p-editor/entity'; +import { + ContentMetadata, + H5PContent, + H5PContentParentType, + IH5PContentProperties, +} from '@src/modules/h5p-editor/entity'; +import { ObjectID } from 'bson'; import { BaseFactory } from './base.factory'; -class H5PContentFactory extends BaseFactory {} +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), diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index f86aef698aa..e4d6fe109ab 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -1,35 +1,40 @@ export * from './account-dto.factory'; export * from './account.factory'; +export * from './axios-response.factory'; +export * from './base.factory'; export * from './board.factory'; export * from './boardelement.factory'; export * from './boardnode'; -export * from './card-element.factory'; -export * from './context-external-tool.factory'; +export * from './context-external-tool-entity.factory'; export * from './course.factory'; export * from './coursegroup.factory'; -export * from './external-tool.factory'; +export * from './domainobject'; +export * from './external-group-dto.factory'; +export * from './external-tool-entity.factory'; +export * from './external-tool-pseudonym.factory'; +export * from './federal-state.factory'; export * from './file.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'; export * from './news.factory'; +export * from './role-dto.factory'; export * from './role.factory'; -export * from './school-external-tool.factory'; +export * from './school-external-tool-entity.factory'; export * from './school.factory'; +export * from './schoolyear.factory'; export * from './share-token.do.factory'; export * from './storageprovider.factory'; export * from './submission.factory'; export * from './system.factory'; -export * from './task-card.factory'; export * from './task.factory'; +export * from './team.factory'; +export * from './teamuser.factory'; export * from './user-and-account.test.factory'; +export * from './user-login-migration.factory'; export * from './user.do.factory'; export * from './user.factory'; -export * from './domainobject'; -export * from './federal-state.factory'; -export * from './user-login-migration.factory'; -export * from './base.factory'; -export * from './external-tool-pseudonym.factory'; -export * from './h5p-content.factory'; -export * from './h5p-temporary-file.factory'; diff --git a/apps/server/src/shared/testing/factory/pseudonym.factory.ts b/apps/server/src/shared/testing/factory/pseudonym.factory.ts index 1d8154995bb..6f2f60e371c 100644 --- a/apps/server/src/shared/testing/factory/pseudonym.factory.ts +++ b/apps/server/src/shared/testing/factory/pseudonym.factory.ts @@ -1,8 +1,8 @@ -import { BaseFactory } from '@shared/testing/factory/base.factory'; import { ObjectId } from '@mikro-orm/mongodb'; -import { IPseudonymEntityProps, PseudonymEntity } from '@src/modules/pseudonym/entity'; +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { PseudonymEntity, PseudonymEntityProps } from '@src/modules/pseudonym/entity'; -export const pseudonymEntityFactory = BaseFactory.define( +export const pseudonymEntityFactory = BaseFactory.define( PseudonymEntity, ({ sequence }) => { return { diff --git a/apps/server/src/shared/testing/factory/role-dto.factory.ts b/apps/server/src/shared/testing/factory/role-dto.factory.ts new file mode 100644 index 00000000000..03d14965d41 --- /dev/null +++ b/apps/server/src/shared/testing/factory/role-dto.factory.ts @@ -0,0 +1,13 @@ +import { RoleName } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { RoleDto } from '@src/modules/role/service/dto/role.dto'; +import { BaseFactory } from './base.factory'; +import { userPermissions } from '../user-role-permissions'; + +export const roleDtoFactory = BaseFactory.define(RoleDto, () => { + return { + id: new ObjectId().toHexString(), + name: RoleName.USER, + permissions: userPermissions, + }; +}); diff --git a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts new file mode 100644 index 00000000000..c991d5abc75 --- /dev/null +++ b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts @@ -0,0 +1,16 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { ISchoolExternalToolProperties, SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; +import { externalToolEntityFactory } from './external-tool-entity.factory'; +import { schoolFactory } from './school.factory'; + +export const schoolExternalToolEntityFactory = BaseFactory.define< + SchoolExternalToolEntity, + ISchoolExternalToolProperties +>(SchoolExternalToolEntity, () => { + return { + tool: externalToolEntityFactory.buildWithId(), + school: schoolFactory.buildWithId(), + schoolParameters: [{ name: 'mockParamater', value: 'mockValue' }], + toolVersion: 0, + }; +}); diff --git a/apps/server/src/shared/testing/factory/school-external-tool.factory.ts b/apps/server/src/shared/testing/factory/school-external-tool.factory.ts deleted file mode 100644 index 9ec7f969ec3..00000000000 --- a/apps/server/src/shared/testing/factory/school-external-tool.factory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { ISchoolExternalToolProperties, SchoolExternalTool } from '@shared/domain'; -import { externalToolFactory } from './external-tool.factory'; -import { schoolFactory } from './school.factory'; - -export const schoolExternalToolFactory = BaseFactory.define( - SchoolExternalTool, - () => { - return { - tool: externalToolFactory.buildWithId(), - school: schoolFactory.buildWithId(), - schoolParameters: [{ name: 'mockParamater', value: 'mockValue' }], - toolVersion: 0, - }; - } -); diff --git a/apps/server/src/shared/testing/factory/task-card.factory.ts b/apps/server/src/shared/testing/factory/task-card.factory.ts deleted file mode 100644 index c33a8d3c678..00000000000 --- a/apps/server/src/shared/testing/factory/task-card.factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CardType, ITaskCardProps, TaskCard } from '@shared/domain'; -import { BaseFactory } from './base.factory'; -import { courseFactory } from './course.factory'; -import { schoolFactory } from './school.factory'; -import { taskFactory } from './task.factory'; -import { userFactory } from './user.factory'; - -class TaskCardFactory extends BaseFactory {} - -export const taskCardFactory = TaskCardFactory.define(TaskCard, () => { - const task = taskFactory.buildWithId(); - const school = schoolFactory.build(); - const creator = userFactory.build({ school }); - const tomorrow = new Date(Date.now() + 86400000); - const inTwoDays = new Date(Date.now() + 172800000); - const course = courseFactory.buildWithId(); - return { - cardType: CardType.Task, - draggable: true, - creator, - task, - cardElements: [], - visibleAtDate: tomorrow, - dueDate: inTwoDays, - title: 'Task Card Title', - course, - }; -}); diff --git a/apps/server/src/shared/testing/factory/task.factory.ts b/apps/server/src/shared/testing/factory/task.factory.ts index d410f77aef4..40162267dd2 100644 --- a/apps/server/src/shared/testing/factory/task.factory.ts +++ b/apps/server/src/shared/testing/factory/task.factory.ts @@ -26,8 +26,8 @@ class TaskFactory extends BaseFactory { return this.params(params); } - finished(user: User | User[]): this { - const params: DeepPartial = { finished: Array.isArray(user) ? user : [user] }; + finished(user: User): this { + const params: DeepPartial = { finished: [user] }; return this.params(params); } } diff --git a/apps/server/src/shared/testing/factory/team.factory.ts b/apps/server/src/shared/testing/factory/team.factory.ts index 35cdd5d45c4..1a72b84969f 100644 --- a/apps/server/src/shared/testing/factory/team.factory.ts +++ b/apps/server/src/shared/testing/factory/team.factory.ts @@ -1,9 +1,9 @@ -import { ITeamProperties, Role, Team, TeamUser } from '@shared/domain'; +import { ITeamProperties, Role, TeamEntity, TeamUserEntity } from '@shared/domain'; import { DeepPartial } from 'fishery'; import { teamUserFactory } from '@shared/testing/factory/teamuser.factory'; import { BaseFactory } from '@shared/testing/factory/base.factory'; -class TeamFactory extends BaseFactory { +class TeamFactory extends BaseFactory { withRoleAndUserId(role: Role, userId: string): this { const params: DeepPartial = { teamUsers: [teamUserFactory.withRoleAndUserId(role, userId).buildWithId()], @@ -11,15 +11,15 @@ class TeamFactory extends BaseFactory { return this.params(params); } - withTeamUser(teamUser: TeamUser): this { + withTeamUser(teamUser: TeamUserEntity[]): this { const params: DeepPartial = { - teamUsers: [teamUser], + teamUsers: teamUser, }; return this.params(params); } } -export const teamFactory = TeamFactory.define(Team, ({ sequence }) => { +export const teamFactory = TeamFactory.define(TeamEntity, ({ sequence }) => { return { name: `team #${sequence}`, teamUsers: [teamUserFactory.buildWithId()], diff --git a/apps/server/src/shared/testing/factory/teamuser.factory.ts b/apps/server/src/shared/testing/factory/teamuser.factory.ts index ffc01a4fb49..34fbba03c51 100644 --- a/apps/server/src/shared/testing/factory/teamuser.factory.ts +++ b/apps/server/src/shared/testing/factory/teamuser.factory.ts @@ -1,28 +1,37 @@ -import { Role, TeamUser } from '@shared/domain'; +import { Role, TeamUserEntity } from '@shared/domain'; import { BaseFactory } from '@shared/testing/factory/base.factory'; import { DeepPartial } from 'fishery'; import { schoolFactory } from '@shared/testing/factory/school.factory'; import { userFactory } from '@shared/testing/factory/user.factory'; import { roleFactory } from '@shared/testing/factory/role.factory'; -class TeamUserFactory extends BaseFactory { +class TeamUserFactory extends BaseFactory { withRoleAndUserId(role: Role, userId: string): this { const school = schoolFactory.build(); - const params: DeepPartial = { + const params: DeepPartial = { user: userFactory.buildWithId({ school, roles: [roleFactory.build({ roles: [role] })] }, userId), school, role, }; return this.params(params); } + + withUserId(userId: string): this { + const school = schoolFactory.build(); + const params: DeepPartial = { + user: userFactory.buildWithId({ school }, userId), + school, + }; + return this.params(params); + } } -export const teamUserFactory = TeamUserFactory.define(TeamUser, () => { +export const teamUserFactory = TeamUserFactory.define(TeamUserEntity, () => { const role = roleFactory.buildWithId(); const school = schoolFactory.buildWithId(); const user = userFactory.buildWithId({ roles: [role] }); - return new TeamUser({ + return new TeamUserEntity({ user, school, role, diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index 4b17d73e7b3..c2a75fe478d 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -100,8 +100,6 @@ export const teacherPermissions = [ Permission.TOPIC_CREATE, Permission.TOPIC_EDIT, Permission.START_MEETING, - Permission.TASK_CARD_VIEW, - Permission.TASK_CARD_EDIT, Permission.CONTEXT_TOOL_ADMIN, ] as Permission[]; diff --git a/apps/server/static-assets/h5p/core/LICENSE.txt b/apps/server/static-assets/h5p/core/LICENSE.txt deleted file mode 100644 index 20d40b6bcec..00000000000 --- a/apps/server/static-assets/h5p/core/LICENSE.txt +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. \ No newline at end of file diff --git a/apps/server/static-assets/h5p/core/README.txt b/apps/server/static-assets/h5p/core/README.txt deleted file mode 100644 index 95aa16f296b..00000000000 --- a/apps/server/static-assets/h5p/core/README.txt +++ /dev/null @@ -1,14 +0,0 @@ -This folder contains the general H5P library. The files within this folder are not specific to any framework. - -Any interaction with an LMS, CMS or other frameworks is done through interfaces. Platforms need to implement -the H5PFrameworkInterface(in h5p.classes.php) and also do the following: - - - Provide a form for uploading H5P packages. - - Place the uploaded H5P packages in a temporary directory - +++ - -See existing implementations for details. For instance the Drupal H5P module located at drupal.org/project/h5p - -We will make available documentation and tutorials for creating platform integrations in the future. - -The H5P PHP library is GPL licensed due to GPL code being used for purifying HTML provided by authors. diff --git a/apps/server/static-assets/h5p/core/doc/spec_en.html b/apps/server/static-assets/h5p/core/doc/spec_en.html deleted file mode 100644 index de6ddf077ae..00000000000 --- a/apps/server/static-assets/h5p/core/doc/spec_en.html +++ /dev/null @@ -1,168 +0,0 @@ -

Overview

-

H5P is a file format for content/applications made using modern, open web technologies (HTML5). The format enables easy installation and transfer of applications/content on different CMSes, LMSes and other platforms. An H5P can be uploaded and published on a platform in mostly the same way one would publish a Flash file today. H5P files may also be updated by simply uploading a new version of the file, the same way as one would using Flash.

-

H5P opens for extensive reuse of code and wide flexibility regarding what may be developed as an H5P.

-

The system uses package files containing all necessary files and libraries for the application to function. These files are based on open formats.

-

Overview of package files

-

Package files are normal zip files, with a naming convention of <filename>.h5p to distinguish from any random zip file. This zip file then requires a specific file structure as described below.

-

There will be a file in JSON format named h5p.json describing the contents of the package and how the system should interpret and use it. This file contains information about title, content type, usage, copyright, licensing, version, language etc. This is described in detail below.

-

There shall be a folder for each included H5P library used by the package. These generic libraries may be reused by other H5P packages. As an example, a multi-choice question task may be used as a standalone block, or be included in a larger H5P package generating a game with quizzes.

-

Package file structure

-

A package contains the following elements:

-
    -
  1. A mandatory file in the root folder named h5p.json
  2. -
  3. An optional image file named h5p.jpg. This is an icon or an image of the application, 512 × 512 pixels. This image may be used by the platform as a preview of the application, and could be included in OG meta tags for use with social media.
  4. -
  5. One content folder, named content. This will contain the preset configuration for the application, as well as any required media files.
  6. -
  7. One or more library directories named the same as the library's internal name.
  8. -
-

h5p.json

-

The h5p.json file is a normal JSON text file containing a JSON object with the following predefined properties.

-

Mandatory properties:

-
    -
  • title - Name of the package. Would typically be used as header for a page displaying the package.
  • -
  • language - Standard language code. Use 'en' for english, 'nb' for norwegian "bokmål". Neutral content use "und".
  • -
  • machineName - Machine readable name of the library. This is the name that will be used for the library folder in the package too.
  • -
  • preloadedDependencies - Libraries that must be loaded on init. Specified as a list of objects with machineName, majorVersion and minorVersion. One would normally list all dependencies here to allow the platform displaying the package to merge JS and CSS files before returning the page.
  • -
  • embedTypes - List of ways to embed the package in the web page. Currently "div" and "iframe" are supported.
  • -

Optional properties:

-
  • contentType - Textual description of the type of content.
  • -
  • description - Textual description of the package.
  • -
  • author - Name of author.
  • -
  • license - Code for the content license. Use the following Creative Commons codes: cc-by, cc-by-sa, cc-by-nd, cc-by-nc, cc-by-nc-sa, cc-by-nc-nd. In addition for public domain: pd, and closed license: cr. More may be added later.
  • -
  • dynamicDependencies - Libraries that may be loaded dynamically during execution.
  • -
  • width - Width of the package content in cases where the package is not dynamically resizable.
  • -
  • height - Height of the package content.
  • -
  • metaKeywords - Suggestion for keywords for the application, as a string. May be used for OG meta tags for social media.
  • -
  • metaDescription - Suggestion for application metaDescription. May be used for OG meta tags for social media.
  • -
-

Eksempel på h5p.json:

-{
- "title": "Biologi-spillet",
- "contentType": "Game",
- "utilization": "Lær om biologi",
- "language": "nb",
- "author": "Amendor AS",
- "license": "cc-by-sa",
- "preloadedDependencies": [
- {
- "machineName": "H5P.Boardgame",
- "majorVersion": 1,
- "minorVersion": 0
- }, {
- "machineName": "H5P.QuestionSet",
- "majorVersion": 1,
- "minorVersion": 0
- }, {
- "machineName": "H5P.MultiChoice",
- "majorVersion": 1, "minorVersion": 0
- }, {
- "machineName": "EmbeddedJS",
- "majorVersion": 1,
- "minorVersion": 0
- } ],
- "embedTypes": ["div", "iframe"],
- "w": 635,
- "h": 500
-}
-

The content folder

-

Contains all the content for the package and its libraries. There shall be no content inside the library folders. The content folder shall contain a file named content.json, containing the JSON object that will be passed to the initializer for the main package library.

- -

Content required by libraries invoked from the main package library will get their contents passed from the main library. The JSON for this will be found within the main content.json for the package, and passed during initialization.

- -

Library folders

- -

A library folder contains all logic, stylesheets and graphics that will be common for all instances of a library. There shall be no content or interface text directly in these folders. All text displayed to the end user shall be passed as part of the library configuration. This make the libraries language independent.

- -

The root of a library folder shall contain a file name library.json formatted similar to the package's hp5.json, but with a few differences. The library shall also have one or more images in the root folder, named library.jpg, library1.jpg etc. Image sizes 512px × 512px, and will be used in the H5P editor tool.

- -

Libraries are not allowed to modify the document tree in ways that will have consequences for the web site or will be noticeable by the user without the library explicitly being initialized from the main package library or another invoked library.

- -

The library shall always include a JavaScript object function named the same as the defined library machineName (defined in library.json and used as the library folder name). This object will be instantiated with the library options as parameter. The resulting object must contain a function attach(target) that will be called after instantiation to attach the library DOM to the main DOM inside target

- -

Example

-

A library called H5P.multichoice would typically be instantiated and attached to the page like this:

-var multichoice = new H5P.multichoice(contentFromJson, contentId);
-multichoice.attach($multichoiceContainer);
- -

library.json

-

Mandatory properties:

-
    -
  • title - Human readable name of the library. May be used in the H5P editor and overviews of installed libraries.
  • -
  • majorVersion - Version major number. (The x in x.y.z). Positive integer.
  • -
  • minorVersion - Version minor number. (The y in x.y.z). Positive integer.
  • -
  • patchVersion - Version patch number. (The z in x.y.z). Positive integer. The system will automatically update to the latest patchVersion installed for all packages that use the library with the same major and minor version number. A new patch version must therefore not change any behaviour of the library, only fix errors.
  • -
  • machineName - Machine readable name for the library. Same as the folder name used.
  • -
  • preloadedJs - List of path to the javascript files required for the library. At least one file need to be present (the one defining the library object). Paths are relative to the library root folder.
  • -
-

Optional properties:

-
    -
  • author - Author name as text.
  • -
  • license - Code describing the library license. Use the following creative commons codes: cc-by, cc-by-sa, cc-by-nd, cc-by-nc, cc-by-nc-sa, cc-by-nc-nd. In addition use pd for public domain, and cr for closed source
  • -
  • description - Textual description of the library.
  • -
  • preloadedDependencies - Libraries that need to be loaded for this library to work. Specified as a list of objects with machineName, majorVersion and minorVersion for the required libraries.
  • -
  • dynamicDependencies - Libraries that may be loaded dynamically during library execution. Specified as a list of objects like preloadedDependencies above.
  • -
  • preloadedCss - List of paths to CSS files to be loaded with the library. Paths are relative to the library root folder.
  • -
  • w - Width in pixels for libraries that use a fixed width. Mandatory if the library shall be embedded in an iframe (see embedTypes below).
  • -
  • h - Height in pixels for libraries that use a fixed height. Mandatory if the library shall be embedded in an iframe (see embedTypes below).
  • -
  • embedTypes - List of possible ways to embed the package in the page. Available values are div and iframe.
  • -
-

Eksempel på library.json:

-{
- "title": "Boardgame",
- "description": "The user is presented with a board with several hotspots. By clicking a hotspot he invokes a mini-game.",
- "majorVersion": 1,
- "minorVersion": 0,
- "patchVersion": 6,
- "runnable": 1,
- "machineName": "H5P.Boardgame",
- "author": "Amendor AS",
- "license": "cc-by-sa",
- "preloadedDependencies": [
- {
- "machineName": "EmbeddedJS",
- "majorVersion": 1,
- "minorVersion": 0
- }, {
- "machineName": "H5P.MultiChoice",
- "majorVersion": 1,
- "minorVersion": 0
- }, {
- "machineName": "H5P.QuestionSet",
- "majorVersion": 1,
- "minorVersion": 0
- } ],
- "preloadedCss": [ {"path": "css/boardgame.css"} ],
- "preloadedJs": [ {"path": "js/boardgame.js"} ],
- "w": 635,
- "h": 500 }
- -

Allowed file types

-

Files that require server side execution or that cannot be regarded an open standard shall not be used. Allowed file types: js, json, png, jpg, gif, svg, css, mp3, wav (audio: PCM), m4a (audio: AAC), mp4 (video: H.264, audio: AAC/MP3), ogg (video: Theora, audio: Vorbis) and webm (video VP8, audio: Vorbis). Administrators of web sites implementing H5P may open for accepting further formats. HTML files shall not be used. HTML for each library shall be inserted from the library scripts to ease code reuse. (By avoiding content being defined in said HTML).

-

API functions

-

The following JavaScript functions are available through h5p:

-
    -
  • H5P.getUserData(namespace, variable)
  • -
  • H5P.setUserData(namespace, variable, data)
  • -
  • H5P.getUserStart(namespace)
  • -
  • H5P.setUserStop(namespace)
  • -
  • H5P.deleteUserData(namespace, variable)
  • -
  • H5P.getGlobalData(namespace, variable)
  • -
  • H5P.setGlobalData(namespace, variable, data)
  • -
  • H5P.deleteGlobalData(namespace, variable)
  • -
-

I tillegg er følgende api funksjoner tilgjengelig via ndla:

-
    -
  • H5P.setUserScore(contentId, score, maxScore)
  • -
-

Best practices

-

H5P is a very open standard. This is positive for flexibility. Most content may be produces as H5P. But this also allows for bad code, security weaknesses, code that may be difficult to reuse. Therefore the following best practices should be followed to get the most from H5P:

-
    -
  • Think reusability when creating a library. H5P support dependencies between libraries, so the same small quiz-library may be used in various larger packages or libraries.
  • -
  • H5P supports library updates. This enables all content using a common library to be updated at once. This must be accounted for when writing new libraries. A library should be as general as possible. The content format should be thought out so there are no changes to the required content data when a library is updated. Note: Multiple versions of a library may exists at the same time, only patch level updates will be automatically installed.
  • -
  • An H5P should not interact directly with the containing web site. It shall only affect elements within its own generated DOM tree. Elements shall also only be injected within the target defined on initialization. This is to avoid dependencies to a specific platform or web page.
  • -
  • Prefix objects, global functions, etc with h5p to minimize the chance of namespace conflicts with the rest of the web page. Remember that there may also be multiple H5P objects inserted on a page, so plan ahead to avoid conflicts.
  • -
  • Content should be responsive.
  • -
  • Content should be WCAG 2 AA compliant
  • -
  • All generated HTML should validate.
  • -
  • All CSS should validate (some browser specific non-standard CSS may at times be required)
  • -
  • Best practices for JavaScript, HTML, etc. should of course also be followed when writing an H5P.
  • -
diff --git a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.eot b/apps/server/static-assets/h5p/core/fonts/h5p-core-23.eot deleted file mode 100644 index 0bd4becbb81..00000000000 Binary files a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.eot and /dev/null differ diff --git a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.svg b/apps/server/static-assets/h5p/core/fonts/h5p-core-23.svg deleted file mode 100644 index 6196c94271d..00000000000 --- a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - -{ - "fontFamily": "h5p-core", - "description": "Font generated by IcoMoon.", - "majorVersion": 1, - "minorVersion": 1, - "version": "Version 1.1", - "fontId": "h5p-core", - "psName": "h5p-core", - "subFamily": "Regular", - "fullName": "h5p-core" -} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.ttf b/apps/server/static-assets/h5p/core/fonts/h5p-core-23.ttf deleted file mode 100644 index e793b63af33..00000000000 Binary files a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.ttf and /dev/null differ diff --git a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.woff b/apps/server/static-assets/h5p/core/fonts/h5p-core-23.woff deleted file mode 100644 index a2f93d0a212..00000000000 Binary files a/apps/server/static-assets/h5p/core/fonts/h5p-core-23.woff and /dev/null differ diff --git a/apps/server/static-assets/h5p/core/images/h5p.svg b/apps/server/static-assets/h5p/core/images/h5p.svg deleted file mode 100644 index 07191aa8b2a..00000000000 --- a/apps/server/static-assets/h5p/core/images/h5p.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - diff --git a/apps/server/static-assets/h5p/core/images/throbber.gif b/apps/server/static-assets/h5p/core/images/throbber.gif deleted file mode 100644 index acddb91dacf..00000000000 Binary files a/apps/server/static-assets/h5p/core/images/throbber.gif and /dev/null differ diff --git a/apps/server/static-assets/h5p/core/js/h5p-action-bar.js b/apps/server/static-assets/h5p/core/js/h5p-action-bar.js deleted file mode 100644 index 608a848b3d9..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-action-bar.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @class - * @augments H5P.EventDispatcher - * @param {Object} displayOptions - * @param {boolean} displayOptions.export Triggers the display of the 'Download' button - * @param {boolean} displayOptions.copyright Triggers the display of the 'Copyright' button - * @param {boolean} displayOptions.embed Triggers the display of the 'Embed' button - * @param {boolean} displayOptions.icon Triggers the display of the 'H5P icon' link - */ -H5P.ActionBar = (function ($, EventDispatcher) { - "use strict"; - - function ActionBar(displayOptions) { - EventDispatcher.call(this); - - /** @alias H5P.ActionBar# */ - var self = this; - - var hasActions = false; - - // Create action bar - var $actions = H5P.jQuery('
    '); - - /** - * Helper for creating action bar buttons. - * - * @private - * @param {string} type - * @param {string} customClass Instead of type class - */ - var addActionButton = function (type, customClass) { - /** - * Handles selection of action - */ - var handler = function () { - self.trigger(type); - }; - H5P.jQuery('
  • ', { - 'class': 'h5p-button h5p-noselect h5p-' + (customClass ? customClass : type), - role: 'button', - tabindex: 0, - title: H5P.t(type + 'Description'), - html: H5P.t(type), - on: { - click: handler, - keypress: function (e) { - if (e.which === 32) { - handler(); - e.preventDefault(); // (since return false will block other inputs) - } - } - }, - appendTo: $actions - }); - - hasActions = true; - }; - - // Register action bar buttons - if (displayOptions.export || displayOptions.copy) { - // Add export button - addActionButton('reuse', 'export'); - } - if (displayOptions.copyright) { - addActionButton('copyrights'); - } - if (displayOptions.embed) { - addActionButton('embed'); - } - if (displayOptions.icon) { - // Add about H5P button icon - H5P.jQuery('
  • ').appendTo($actions); - hasActions = true; - } - - /** - * Returns a reference to the dom element - * - * @return {H5P.jQuery} - */ - self.getDOMElement = function () { - return $actions; - }; - - /** - * Does the actionbar contain actions? - * - * @return {Boolean} - */ - self.hasActions = function () { - return hasActions; - }; - } - - ActionBar.prototype = Object.create(EventDispatcher.prototype); - ActionBar.prototype.constructor = ActionBar; - - return ActionBar; - -})(H5P.jQuery, H5P.EventDispatcher); diff --git a/apps/server/static-assets/h5p/core/js/h5p-confirmation-dialog.js b/apps/server/static-assets/h5p/core/js/h5p-confirmation-dialog.js deleted file mode 100644 index cd3536e7a40..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-confirmation-dialog.js +++ /dev/null @@ -1,410 +0,0 @@ -/*global H5P*/ -H5P.ConfirmationDialog = (function (EventDispatcher) { - "use strict"; - - /** - * Create a confirmation dialog - * - * @param [options] Options for confirmation dialog - * @param [options.instance] Instance that uses confirmation dialog - * @param [options.headerText] Header text - * @param [options.dialogText] Dialog text - * @param [options.cancelText] Cancel dialog button text - * @param [options.confirmText] Confirm dialog button text - * @param [options.hideCancel] Hide cancel button - * @param [options.hideExit] Hide exit button - * @param [options.skipRestoreFocus] Skip restoring focus when hiding the dialog - * @param [options.classes] Extra classes for popup - * @constructor - */ - function ConfirmationDialog(options) { - EventDispatcher.call(this); - var self = this; - - // Make sure confirmation dialogs have unique id - H5P.ConfirmationDialog.uniqueId += 1; - var uniqueId = H5P.ConfirmationDialog.uniqueId; - - // Default options - options = options || {}; - options.headerText = options.headerText || H5P.t('confirmDialogHeader'); - options.dialogText = options.dialogText || H5P.t('confirmDialogBody'); - options.cancelText = options.cancelText || H5P.t('cancelLabel'); - options.confirmText = options.confirmText || H5P.t('confirmLabel'); - - /** - * Handle confirming event - * @param {Event} e - */ - function dialogConfirmed(e) { - self.hide(); - self.trigger('confirmed'); - e.preventDefault(); - } - - /** - * Handle dialog canceled - * @param {Event} e - */ - function dialogCanceled(e) { - self.hide(); - self.trigger('canceled'); - e.preventDefault(); - } - - /** - * Flow focus to element - * @param {HTMLElement} element Next element to be focused - * @param {Event} e Original tab event - */ - function flowTo(element, e) { - element.focus(); - e.preventDefault(); - } - - // Offset of exit button - var exitButtonOffset = 2 * 16; - var shadowOffset = 8; - - // Determine if we are too large for our container and must resize - var resizeIFrame = false; - - // Create background - var popupBackground = document.createElement('div'); - popupBackground.classList - .add('h5p-confirmation-dialog-background', 'hidden', 'hiding'); - - // Create outer popup - var popup = document.createElement('div'); - popup.classList.add('h5p-confirmation-dialog-popup', 'hidden'); - if (options.classes) { - options.classes.forEach(function (popupClass) { - popup.classList.add(popupClass); - }); - } - - popup.setAttribute('role', 'dialog'); - popup.setAttribute('aria-labelledby', 'h5p-confirmation-dialog-dialog-text-' + uniqueId); - popupBackground.appendChild(popup); - popup.addEventListener('keydown', function (e) { - if (e.which === 27) {// Esc key - // Exit dialog - dialogCanceled(e); - } - }); - - // Popup header - var header = document.createElement('div'); - header.classList.add('h5p-confirmation-dialog-header'); - popup.appendChild(header); - - // Header text - var headerText = document.createElement('div'); - headerText.classList.add('h5p-confirmation-dialog-header-text'); - headerText.innerHTML = options.headerText; - header.appendChild(headerText); - - // Popup body - var body = document.createElement('div'); - body.classList.add('h5p-confirmation-dialog-body'); - popup.appendChild(body); - - // Popup text - var text = document.createElement('div'); - text.classList.add('h5p-confirmation-dialog-text'); - text.innerHTML = options.dialogText; - text.id = 'h5p-confirmation-dialog-dialog-text-' + uniqueId; - body.appendChild(text); - - // Popup buttons - var buttons = document.createElement('div'); - buttons.classList.add('h5p-confirmation-dialog-buttons'); - body.appendChild(buttons); - - // Cancel button - var cancelButton = document.createElement('button'); - cancelButton.classList.add('h5p-core-cancel-button'); - cancelButton.textContent = options.cancelText; - - // Confirm button - var confirmButton = document.createElement('button'); - confirmButton.classList.add('h5p-core-button'); - confirmButton.classList.add('h5p-confirmation-dialog-confirm-button'); - confirmButton.textContent = options.confirmText; - - // Exit button - var exitButton = document.createElement('button'); - exitButton.classList.add('h5p-confirmation-dialog-exit'); - exitButton.setAttribute('aria-hidden', 'true'); - exitButton.tabIndex = -1; - exitButton.title = options.cancelText; - - // Cancel handler - cancelButton.addEventListener('click', dialogCanceled); - cancelButton.addEventListener('keydown', function (e) { - if (e.which === 32) { // Space - dialogCanceled(e); - } - else if (e.which === 9 && e.shiftKey) { // Shift-tab - flowTo(confirmButton, e); - } - }); - - if (!options.hideCancel) { - buttons.appendChild(cancelButton); - } - else { - // Center buttons - buttons.classList.add('center'); - } - - // Confirm handler - confirmButton.addEventListener('click', dialogConfirmed); - confirmButton.addEventListener('keydown', function (e) { - if (e.which === 32) { // Space - dialogConfirmed(e); - } - else if (e.which === 9 && !e.shiftKey) { // Tab - const nextButton = !options.hideCancel ? cancelButton : confirmButton; - flowTo(nextButton, e); - } - }); - buttons.appendChild(confirmButton); - - // Exit handler - exitButton.addEventListener('click', dialogCanceled); - exitButton.addEventListener('keydown', function (e) { - if (e.which === 32) { // Space - dialogCanceled(e); - } - }); - if (!options.hideExit) { - popup.appendChild(exitButton); - } - - // Wrapper element - var wrapperElement; - - // Focus capturing - var focusPredator; - - // Maintains hidden state of elements - var wrapperSiblingsHidden = []; - var popupSiblingsHidden = []; - - // Element with focus before dialog - var previouslyFocused; - - /** - * Set parent of confirmation dialog - * @param {HTMLElement} wrapper - * @returns {H5P.ConfirmationDialog} - */ - this.appendTo = function (wrapper) { - wrapperElement = wrapper; - return this; - }; - - /** - * Capture the focus element, send it to confirmation button - * @param {Event} e Original focus event - */ - var captureFocus = function (e) { - if (!popupBackground.contains(e.target)) { - e.preventDefault(); - confirmButton.focus(); - } - }; - - /** - * Hide siblings of element from assistive technology - * - * @param {HTMLElement} element - * @returns {Array} The previous hidden state of all siblings - */ - var hideSiblings = function (element) { - var hiddenSiblings = []; - var siblings = element.parentNode.children; - var i; - for (i = 0; i < siblings.length; i += 1) { - // Preserve hidden state - hiddenSiblings[i] = siblings[i].getAttribute('aria-hidden') ? - true : false; - - if (siblings[i] !== element) { - siblings[i].setAttribute('aria-hidden', true); - } - } - return hiddenSiblings; - }; - - /** - * Restores assistive technology state of element's siblings - * - * @param {HTMLElement} element - * @param {Array} hiddenSiblings Hidden state of all siblings - */ - var restoreSiblings = function (element, hiddenSiblings) { - var siblings = element.parentNode.children; - var i; - for (i = 0; i < siblings.length; i += 1) { - if (siblings[i] !== element && !hiddenSiblings[i]) { - siblings[i].removeAttribute('aria-hidden'); - } - } - }; - - /** - * Start capturing focus of parent and send it to dialog - */ - var startCapturingFocus = function () { - focusPredator = wrapperElement.parentNode || wrapperElement; - focusPredator.addEventListener('focus', captureFocus, true); - }; - - /** - * Clean up event listener for capturing focus - */ - var stopCapturingFocus = function () { - focusPredator.removeAttribute('aria-hidden'); - focusPredator.removeEventListener('focus', captureFocus, true); - }; - - /** - * Hide siblings in underlay from assistive technologies - */ - var disableUnderlay = function () { - wrapperSiblingsHidden = hideSiblings(wrapperElement); - popupSiblingsHidden = hideSiblings(popupBackground); - }; - - /** - * Restore state of underlay for assistive technologies - */ - var restoreUnderlay = function () { - restoreSiblings(wrapperElement, wrapperSiblingsHidden); - restoreSiblings(popupBackground, popupSiblingsHidden); - }; - - /** - * Fit popup to container. Makes sure it doesn't overflow. - * @params {number} [offsetTop] Offset of popup - */ - var fitToContainer = function (offsetTop) { - var popupOffsetTop = parseInt(popup.style.top, 10); - if (offsetTop !== undefined) { - popupOffsetTop = offsetTop; - } - - if (!popupOffsetTop) { - popupOffsetTop = 0; - } - - // Overflows height - if (popupOffsetTop + popup.offsetHeight > wrapperElement.offsetHeight) { - popupOffsetTop = wrapperElement.offsetHeight - popup.offsetHeight - shadowOffset; - } - - if (popupOffsetTop - exitButtonOffset <= 0) { - popupOffsetTop = exitButtonOffset + shadowOffset; - - // We are too big and must resize - resizeIFrame = true; - } - popup.style.top = popupOffsetTop + 'px'; - }; - - /** - * Show confirmation dialog - * @params {number} offsetTop Offset top - * @returns {H5P.ConfirmationDialog} - */ - this.show = function (offsetTop) { - // Capture focused item - previouslyFocused = document.activeElement; - wrapperElement.appendChild(popupBackground); - startCapturingFocus(); - disableUnderlay(); - popupBackground.classList.remove('hidden'); - fitToContainer(offsetTop); - setTimeout(function () { - popup.classList.remove('hidden'); - popupBackground.classList.remove('hiding'); - - setTimeout(function () { - // Focus confirm button - confirmButton.focus(); - - // Resize iFrame if necessary - if (resizeIFrame && options.instance) { - var minHeight = parseInt(popup.offsetHeight, 10) + - exitButtonOffset + (2 * shadowOffset); - self.setViewPortMinimumHeight(minHeight); - options.instance.trigger('resize'); - resizeIFrame = false; - } - }, 100); - }, 0); - - return this; - }; - - /** - * Hide confirmation dialog - * @returns {H5P.ConfirmationDialog} - */ - this.hide = function () { - popupBackground.classList.add('hiding'); - popup.classList.add('hidden'); - - // Restore focus - stopCapturingFocus(); - if (!options.skipRestoreFocus) { - previouslyFocused.focus(); - } - restoreUnderlay(); - setTimeout(function () { - popupBackground.classList.add('hidden'); - wrapperElement.removeChild(popupBackground); - self.setViewPortMinimumHeight(null); - }, 100); - - return this; - }; - - /** - * Retrieve element - * - * @return {HTMLElement} - */ - this.getElement = function () { - return popup; - }; - - /** - * Get previously focused element - * @return {HTMLElement} - */ - this.getPreviouslyFocused = function () { - return previouslyFocused; - }; - - /** - * Sets the minimum height of the view port - * - * @param {number|null} minHeight - */ - this.setViewPortMinimumHeight = function (minHeight) { - var container = document.querySelector('.h5p-container') || document.body; - container.style.minHeight = (typeof minHeight === 'number') ? (minHeight + 'px') : minHeight; - }; - } - - ConfirmationDialog.prototype = Object.create(EventDispatcher.prototype); - ConfirmationDialog.prototype.constructor = ConfirmationDialog; - - return ConfirmationDialog; - -}(H5P.EventDispatcher)); - -H5P.ConfirmationDialog.uniqueId = -1; diff --git a/apps/server/static-assets/h5p/core/js/h5p-content-type.js b/apps/server/static-assets/h5p/core/js/h5p-content-type.js deleted file mode 100644 index 47c4d21bf75..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-content-type.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * H5P.ContentType is a base class for all content types. Used by newRunnable() - * - * Functions here may be overridable by the libraries. In special cases, - * it is also possible to override H5P.ContentType on a global level. - * - * NOTE that this doesn't actually 'extend' the event dispatcher but instead - * it creates a single instance which all content types shares as their base - * prototype. (in some cases this may be the root of strange event behavior) - * - * @class - * @augments H5P.EventDispatcher - */ -H5P.ContentType = function (isRootLibrary) { - - function ContentType() {} - - // Inherit from EventDispatcher. - ContentType.prototype = new H5P.EventDispatcher(); - - /** - * Is library standalone or not? Not beeing standalone, means it is - * included in another library - * - * @return {Boolean} - */ - ContentType.prototype.isRoot = function () { - return isRootLibrary; - }; - - /** - * Returns the file path of a file in the current library - * @param {string} filePath The path to the file relative to the library folder - * @return {string} The full path to the file - */ - ContentType.prototype.getLibraryFilePath = function (filePath) { - return H5P.getLibraryPath(this.libraryInfo.versionedNameNoSpaces) + '/' + filePath; - }; - - return ContentType; -}; diff --git a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-process.js b/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-process.js deleted file mode 100644 index fbaa4f2bf07..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-process.js +++ /dev/null @@ -1,313 +0,0 @@ -/*jshint -W083 */ -var H5PUpgrades = H5PUpgrades || {}; - -H5P.ContentUpgradeProcess = (function (Version) { - - /** - * @class - * @namespace H5P - */ - function ContentUpgradeProcess(name, oldVersion, newVersion, params, id, loadLibrary, done) { - var self = this; - - // Make params possible to work with - try { - params = JSON.parse(params); - if (!(params instanceof Object)) { - throw true; - } - } - catch (event) { - return done({ - type: 'errorParamsBroken', - id: id - }); - } - - self.loadLibrary = loadLibrary; - self.upgrade(name, oldVersion, newVersion, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { - if (err) { - err.id = id; - return done(err); - } - - done(null, JSON.stringify({params: upgradedParams, metadata: upgradedMetadata})); - }); - } - - /** - * Run content upgrade. - * - * @public - * @param {string} name - * @param {Version} oldVersion - * @param {Version} newVersion - * @param {Object} params - * @param {Object} metadata - * @param {Function} done - */ - ContentUpgradeProcess.prototype.upgrade = function (name, oldVersion, newVersion, params, metadata, done) { - var self = this; - - // Load library details and upgrade routines - self.loadLibrary(name, newVersion, function (err, library) { - if (err) { - return done(err); - } - if (library.semantics === null) { - return done({ - type: 'libraryMissing', - library: library.name + ' ' + library.version.major + '.' + library.version.minor - }); - } - - // Run upgrade routines on params - self.processParams(library, oldVersion, newVersion, params, metadata, function (err, params, metadata) { - if (err) { - return done(err); - } - - // Check if any of the sub-libraries need upgrading - asyncSerial(library.semantics, function (index, field, next) { - self.processField(field, params[field.name], function (err, upgradedParams) { - if (upgradedParams) { - params[field.name] = upgradedParams; - } - next(err); - }); - }, function (err) { - done(err, params, metadata); - }); - }); - }); - }; - - /** - * Run upgrade hooks on params. - * - * @public - * @param {Object} library - * @param {Version} oldVersion - * @param {Version} newVersion - * @param {Object} params - * @param {Function} next - */ - ContentUpgradeProcess.prototype.processParams = function (library, oldVersion, newVersion, params, metadata, next) { - if (H5PUpgrades[library.name] === undefined) { - if (library.upgradesScript) { - // Upgrades script should be loaded so the upgrades should be here. - return next({ - type: 'scriptMissing', - library: library.name + ' ' + newVersion - }); - } - - // No upgrades script. Move on - return next(null, params, metadata); - } - - // Run upgrade hooks. Start by going through major versions - asyncSerial(H5PUpgrades[library.name], function (major, minors, nextMajor) { - if (major < oldVersion.major || major > newVersion.major) { - // Older than the current version or newer than the selected - nextMajor(); - } - else { - // Go through the minor versions for this major version - asyncSerial(minors, function (minor, upgrade, nextMinor) { - minor =+ minor; - if (minor <= oldVersion.minor || minor > newVersion.minor) { - // Older than or equal to the current version or newer than the selected - nextMinor(); - } - else { - // We found an upgrade hook, run it - var unnecessaryWrapper = (upgrade.contentUpgrade !== undefined ? upgrade.contentUpgrade : upgrade); - - try { - unnecessaryWrapper(params, function (err, upgradedParams, upgradedExtras) { - params = upgradedParams; - if (upgradedExtras && upgradedExtras.metadata) { // Optional - metadata = upgradedExtras.metadata; - } - nextMinor(err); - }, {metadata: metadata}); - } - catch (err) { - if (console && console.error) { - console.error("Error", err.stack); - console.error("Error", err.name); - console.error("Error", err.message); - } - next(err); - } - } - }, nextMajor); - } - }, function (err) { - next(err, params, metadata); - }); - }; - - /** - * Process parameter fields to find and upgrade sub-libraries. - * - * @public - * @param {Object} field - * @param {Object} params - * @param {Function} done - */ - ContentUpgradeProcess.prototype.processField = function (field, params, done) { - var self = this; - - if (params === undefined) { - return done(); - } - - switch (field.type) { - case 'library': - if (params.library === undefined || params.params === undefined) { - return done(); - } - - // Look for available upgrades - var usedLib = params.library.split(' ', 2); - for (var i = 0; i < field.options.length; i++) { - var availableLib = (typeof field.options[i] === 'string') ? field.options[i].split(' ', 2) : field.options[i].name.split(' ', 2); - if (availableLib[0] === usedLib[0]) { - if (availableLib[1] === usedLib[1]) { - return done(); // Same version - } - - // We have different versions - var usedVer = new Version(usedLib[1]); - var availableVer = new Version(availableLib[1]); - if (usedVer.major > availableVer.major || (usedVer.major === availableVer.major && usedVer.minor >= availableVer.minor)) { - return done({ - type: 'errorTooHighVersion', - used: usedLib[0] + ' ' + usedVer, - supported: availableLib[0] + ' ' + availableVer - }); // Larger or same version that's available - } - - // A newer version is available, upgrade params - return self.upgrade(availableLib[0], usedVer, availableVer, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { - if (!err) { - params.library = availableLib[0] + ' ' + availableVer.major + '.' + availableVer.minor; - params.params = upgradedParams; - if (upgradedMetadata) { - params.metadata = upgradedMetadata; - } - } - done(err, params); - }); - } - } - - // Content type was not supporte by the higher version - done({ - type: 'errorNotSupported', - used: usedLib[0] + ' ' + usedVer - }); - break; - - case 'group': - if (field.fields.length === 1 && field.isSubContent !== true) { - // Single field to process, wrapper will be skipped - self.processField(field.fields[0], params, function (err, upgradedParams) { - if (upgradedParams) { - params = upgradedParams; - } - done(err, params); - }); - } - else { - // Go through all fields in the group - asyncSerial(field.fields, function (index, subField, next) { - var paramsToProcess = params ? params[subField.name] : null; - self.processField(subField, paramsToProcess, function (err, upgradedParams) { - if (upgradedParams) { - params[subField.name] = upgradedParams; - } - next(err); - }); - - }, function (err) { - done(err, params); - }); - } - break; - - case 'list': - // Go trough all params in the list - asyncSerial(params, function (index, subParams, next) { - self.processField(field.field, subParams, function (err, upgradedParams) { - if (upgradedParams) { - params[index] = upgradedParams; - } - next(err); - }); - }, function (err) { - done(err, params); - }); - break; - - default: - done(); - } - }; - - /** - * Helps process each property on the given object asynchronously in serial order. - * - * @private - * @param {Object} obj - * @param {Function} process - * @param {Function} finished - */ - var asyncSerial = function (obj, process, finished) { - var id, isArray = obj instanceof Array; - - // Keep track of each property that belongs to this object. - if (!isArray) { - var ids = []; - for (id in obj) { - if (obj.hasOwnProperty(id)) { - ids.push(id); - } - } - } - - var i = -1; // Keeps track of the current property - - /** - * Private. Process the next property - */ - var next = function () { - id = isArray ? i : ids[i]; - process(id, obj[id], check); - }; - - /** - * Private. Check if we're done or have an error. - * - * @param {String} err - */ - var check = function (err) { - // We need to use a real async function in order for the stack to clear. - setTimeout(function () { - i++; - if (i === (isArray ? obj.length : ids.length) || (err !== undefined && err !== null)) { - finished(err); - } - else { - next(); - } - }, 0); - }; - - check(); // Start - }; - - return ContentUpgradeProcess; -})(H5P.Version); diff --git a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-worker.js b/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-worker.js deleted file mode 100644 index 3507a358a1a..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade-worker.js +++ /dev/null @@ -1,63 +0,0 @@ -/* global importScripts */ -var H5P = H5P || {}; -importScripts('h5p-version.js', 'h5p-content-upgrade-process.js'); - -var libraryLoadedCallback; - -/** - * Register message handlers - */ -var messageHandlers = { - newJob: function (job) { - // Start new job - new H5P.ContentUpgradeProcess(job.name, new H5P.Version(job.oldVersion), new H5P.Version(job.newVersion), job.params, job.id, function loadLibrary(name, version, next) { - // TODO: Cache? - postMessage({ - action: 'loadLibrary', - name: name, - version: version.toString() - }); - libraryLoadedCallback = next; - }, function done(err, result) { - if (err) { - // Return error - postMessage({ - action: 'error', - id: job.id, - err: err.message ? err.message : err - }); - - return; - } - - // Return upgraded content - postMessage({ - action: 'done', - id: job.id, - params: result - }); - }); - }, - libraryLoaded: function (data) { - var library = data.library; - if (library.upgradesScript) { - try { - importScripts(library.upgradesScript); - } - catch (err) { - libraryLoadedCallback(err); - return; - } - } - libraryLoadedCallback(null, data.library); - } -}; - -/** - * Handle messages from our master - */ -onmessage = function (event) { - if (event.data.action !== undefined && messageHandlers[event.data.action]) { - messageHandlers[event.data.action].call(this, event.data); - } -}; diff --git a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade.js b/apps/server/static-assets/h5p/core/js/h5p-content-upgrade.js deleted file mode 100644 index 9dc066c5c25..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-content-upgrade.js +++ /dev/null @@ -1,445 +0,0 @@ -/* global H5PAdminIntegration H5PUtils */ - -(function ($, Version) { - var info, $log, $container, librariesCache = {}, scriptsCache = {}; - - // Initialize - $(document).ready(function () { - // Get library info - info = H5PAdminIntegration.libraryInfo; - - // Get and reset container - const $wrapper = $('#h5p-admin-container').html(''); - $log = $('
      ').appendTo($wrapper); - $container = $('

      ' + info.message + '

      ').appendTo($wrapper); - - // Make it possible to select version - var $version = $(getVersionSelect(info.versions)).appendTo($container); - - // Add "go" button - $(''); - H5PLibraryDetails.$next = $(''); - - H5PLibraryDetails.$previous.on('click', function () { - if (H5PLibraryDetails.$previous.hasClass('disabled')) { - return; - } - - H5PLibraryDetails.currentPage--; - H5PLibraryDetails.updatePager(); - H5PLibraryDetails.createContentTable(); - }); - - H5PLibraryDetails.$next.on('click', function () { - if (H5PLibraryDetails.$next.hasClass('disabled')) { - return; - } - - H5PLibraryDetails.currentPage++; - H5PLibraryDetails.updatePager(); - H5PLibraryDetails.createContentTable(); - }); - - // This is the Page x of y widget: - H5PLibraryDetails.$pagerInfo = $(''); - - H5PLibraryDetails.$pager = $('
      ').append(H5PLibraryDetails.$previous, H5PLibraryDetails.$pagerInfo, H5PLibraryDetails.$next); - H5PLibraryDetails.$content.append(H5PLibraryDetails.$pager); - - H5PLibraryDetails.$pagerInfo.on('click', function () { - var width = H5PLibraryDetails.$pagerInfo.innerWidth(); - H5PLibraryDetails.$pagerInfo.hide(); - - // User has updated the pageNumber - var pageNumerUpdated = function () { - var newPageNum = $gotoInput.val()-1; - var intRegex = /^\d+$/; - - $goto.remove(); - H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); - - // Check if input value is valid, and that it has actually changed - if (!(intRegex.test(newPageNum) && newPageNum >= 0 && newPageNum < H5PLibraryDetails.getNumPages() && newPageNum != H5PLibraryDetails.currentPage)) { - return; - } - - H5PLibraryDetails.currentPage = newPageNum; - H5PLibraryDetails.updatePager(); - H5PLibraryDetails.createContentTable(); - }; - - // We create an input box where the user may type in the page number - // he wants to be displayed. - // Reson for doing this is when user has ten-thousands of elements in list, - // this is the easiest way of getting to a specified page - var $gotoInput = $('', { - type: 'number', - min : 1, - max: H5PLibraryDetails.getNumPages(), - on: { - // Listen to blur, and the enter-key: - 'blur': pageNumerUpdated, - 'keyup': function (event) { - if (event.keyCode === 13) { - pageNumerUpdated(); - } - } - } - }).css({width: width}); - var $goto = $('', { - 'class': 'h5p-pager-goto' - }).css({width: width}).append($gotoInput).insertAfter(H5PLibraryDetails.$pagerInfo); - - $gotoInput.focus(); - }); - - H5PLibraryDetails.updatePager(); - }; - - /** - * Calculates number of pages - */ - H5PLibraryDetails.getNumPages = function () { - return Math.ceil(H5PLibraryDetails.currentContent.length / H5PLibraryDetails.PAGER_SIZE); - }; - - /** - * Update the pager text, and enables/disables the next and previous buttons as needed - */ - H5PLibraryDetails.updatePager = function () { - H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); - - if (H5PLibraryDetails.getNumPages() > 0) { - var message = H5PUtils.translateReplace(H5PLibraryDetails.library.translations.pageXOfY, { - '$x': (H5PLibraryDetails.currentPage+1), - '$y': H5PLibraryDetails.getNumPages() - }); - H5PLibraryDetails.$pagerInfo.html(message); - } - else { - H5PLibraryDetails.$pagerInfo.html(''); - } - - H5PLibraryDetails.$previous.toggleClass('disabled', H5PLibraryDetails.currentPage <= 0); - H5PLibraryDetails.$next.toggleClass('disabled', H5PLibraryDetails.currentContent.length < (H5PLibraryDetails.currentPage+1)*H5PLibraryDetails.PAGER_SIZE); - }; - - /** - * Creates the search element - */ - H5PLibraryDetails.createSearchElement = function () { - - H5PLibraryDetails.$search = $(''); - - var performSeach = function () { - var searchString = $('.h5p-content-search > input').val(); - - // If search string same as previous, just do nothing - if (H5PLibraryDetails.currentFilter === searchString) { - return; - } - - if (searchString.trim().length === 0) { - // If empty search, use the complete list - H5PLibraryDetails.currentContent = H5PLibraryDetails.library.content; - } - else if (H5PLibraryDetails.filterCache[searchString]) { - // If search is cached, no need to filter - H5PLibraryDetails.currentContent = H5PLibraryDetails.filterCache[searchString]; - } - else { - var listToFilter = H5PLibraryDetails.library.content; - - // Check if we can filter the already filtered results (for performance) - if (searchString.length > 1 && H5PLibraryDetails.currentFilter === searchString.substr(0, H5PLibraryDetails.currentFilter.length)) { - listToFilter = H5PLibraryDetails.currentContent; - } - H5PLibraryDetails.currentContent = $.grep(listToFilter, function (content) { - return content.title && content.title.match(new RegExp(searchString, 'i')); - }); - } - - H5PLibraryDetails.currentFilter = searchString; - // Cache the current result - H5PLibraryDetails.filterCache[searchString] = H5PLibraryDetails.currentContent; - H5PLibraryDetails.currentPage = 0; - H5PLibraryDetails.createContentTable(); - - // Display search results: - if (H5PLibraryDetails.$searchResults) { - H5PLibraryDetails.$searchResults.remove(); - } - if (searchString.trim().length > 0) { - H5PLibraryDetails.$searchResults = $('' + H5PLibraryDetails.currentContent.length + ' hits on ' + H5PLibraryDetails.currentFilter + ''); - H5PLibraryDetails.$search.append(H5PLibraryDetails.$searchResults); - } - H5PLibraryDetails.updatePager(); - }; - - var inputTimer; - $('input', H5PLibraryDetails.$search).on('change keypress paste input', function () { - // Here we start the filtering - // We wait at least 500 ms after last input to perform search - if (inputTimer) { - clearTimeout(inputTimer); - } - - inputTimer = setTimeout( function () { - performSeach(); - }, 500); - }); - - H5PLibraryDetails.$content.append(H5PLibraryDetails.$search); - }; - - /** - * Creates the page size selector - */ - H5PLibraryDetails.createPageSizeSelector = function () { - H5PLibraryDetails.$search.append('
      ' + H5PLibraryDetails.library.translations.pageSizeSelectorLabel + ':102050100200
      '); - - // Listen to clicks on the page size selector: - $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).on('click', function () { - H5PLibraryDetails.PAGER_SIZE = $(this).data('page-size'); - $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).removeClass('selected'); - $(this).addClass('selected'); - H5PLibraryDetails.currentPage = 0; - H5PLibraryDetails.createContentTable(); - H5PLibraryDetails.updatePager(); - }); - }; - - // Initialize me: - $(document).ready(function () { - if (!H5PLibraryDetails.initialized) { - H5PLibraryDetails.initialized = true; - H5PLibraryDetails.init(); - } - }); - -})(H5P.jQuery); diff --git a/apps/server/static-assets/h5p/core/js/h5p-library-list.js b/apps/server/static-assets/h5p/core/js/h5p-library-list.js deleted file mode 100644 index 344b7367234..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-library-list.js +++ /dev/null @@ -1,140 +0,0 @@ -/* global H5PAdminIntegration H5PUtils */ -var H5PLibraryList = H5PLibraryList || {}; - -(function ($) { - - /** - * Initializing - */ - H5PLibraryList.init = function () { - var $adminContainer = H5P.jQuery(H5PAdminIntegration.containerSelector).html(''); - - var libraryList = H5PAdminIntegration.libraryList; - if (libraryList.notCached) { - $adminContainer.append(H5PUtils.getRebuildCache(libraryList.notCached)); - } - - // Create library list - $adminContainer.append(H5PLibraryList.createLibraryList(H5PAdminIntegration.libraryList)); - }; - - /** - * Create the library list - * - * @param {object} libraries List of libraries and headers - */ - H5PLibraryList.createLibraryList = function (libraries) { - var t = H5PAdminIntegration.l10n; - if (libraries.listData === undefined || libraries.listData.length === 0) { - return $('
      ' + t.NA + '
      '); - } - - // Create table - var $table = H5PUtils.createTable(libraries.listHeaders); - $table.addClass('libraries'); - - // Add libraries - $.each (libraries.listData, function (index, library) { - var $libraryRow = H5PUtils.createTableRow([ - library.title, - '', - { - text: library.numContent, - class: 'h5p-admin-center' - }, - { - text: library.numContentDependencies, - class: 'h5p-admin-center' - }, - { - text: library.numLibraryDependencies, - class: 'h5p-admin-center' - }, - '
      ' + - '' + - (library.detailsUrl ? '' : '') + - (library.deleteUrl ? '' : '') + - '
      ' - ]); - - H5PLibraryList.addRestricted($('.h5p-admin-restricted', $libraryRow), library.restrictedUrl, library.restricted); - - var hasContent = !(library.numContent === '' || library.numContent === 0); - if (library.upgradeUrl === null) { - $('.h5p-admin-upgrade-library', $libraryRow).remove(); - } - else if (library.upgradeUrl === false || !hasContent) { - $('.h5p-admin-upgrade-library', $libraryRow).attr('disabled', true); - } - else { - $('.h5p-admin-upgrade-library', $libraryRow).attr('title', t.upgradeLibrary).click(function () { - window.location.href = library.upgradeUrl; - }); - } - - // Open details view when clicked - $('.h5p-admin-view-library', $libraryRow).on('click', function () { - window.location.href = library.detailsUrl; - }); - - var $deleteButton = $('.h5p-admin-delete-library', $libraryRow); - if (libraries.notCached !== undefined || - hasContent || - (library.numContentDependencies !== '' && - library.numContentDependencies !== 0) || - (library.numLibraryDependencies !== '' && - library.numLibraryDependencies !== 0)) { - // Disabled delete if content. - $deleteButton.attr('disabled', true); - } - else { - // Go to delete page om click. - $deleteButton.attr('title', t.deleteLibrary).on('click', function () { - window.location.href = library.deleteUrl; - }); - } - - $table.append($libraryRow); - }); - - return $table; - }; - - H5PLibraryList.addRestricted = function ($checkbox, url, selected) { - if (selected === null) { - $checkbox.remove(); - } - else { - $checkbox.change(function () { - $checkbox.attr('disabled', true); - - $.ajax({ - dataType: 'json', - url: url, - cache: false - }).fail(function () { - $checkbox.attr('disabled', false); - - // Reset - $checkbox.attr('checked', !$checkbox.is(':checked')); - }).done(function (result) { - url = result.url; - $checkbox.attr('disabled', false); - }); - }); - - if (selected) { - $checkbox.attr('checked', true); - } - } - }; - - // Initialize me: - $(document).ready(function () { - if (!H5PLibraryList.initialized) { - H5PLibraryList.initialized = true; - H5PLibraryList.init(); - } - }); - -})(H5P.jQuery); diff --git a/apps/server/static-assets/h5p/core/js/h5p-resizer.js b/apps/server/static-assets/h5p/core/js/h5p-resizer.js deleted file mode 100644 index ed78724ec1a..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-resizer.js +++ /dev/null @@ -1,131 +0,0 @@ -// H5P iframe Resizer -(function () { - if (!window.postMessage || !window.addEventListener || window.h5pResizerInitialized) { - return; // Not supported - } - window.h5pResizerInitialized = true; - - // Map actions to handlers - var actionHandlers = {}; - - /** - * Prepare iframe resize. - * - * @private - * @param {Object} iframe Element - * @param {Object} data Payload - * @param {Function} respond Send a response to the iframe - */ - actionHandlers.hello = function (iframe, data, respond) { - // Make iframe responsive - iframe.style.width = '100%'; - - // Bugfix for Chrome: Force update of iframe width. If this is not done the - // document size may not be updated before the content resizes. - iframe.getBoundingClientRect(); - - // Tell iframe that it needs to resize when our window resizes - var resize = function () { - if (iframe.contentWindow) { - // Limit resize calls to avoid flickering - respond('resize'); - } - else { - // Frame is gone, unregister. - window.removeEventListener('resize', resize); - } - }; - window.addEventListener('resize', resize, false); - - // Respond to let the iframe know we can resize it - respond('hello'); - }; - - /** - * Prepare iframe resize. - * - * @private - * @param {Object} iframe Element - * @param {Object} data Payload - * @param {Function} respond Send a response to the iframe - */ - actionHandlers.prepareResize = function (iframe, data, respond) { - // Do not resize unless page and scrolling differs - if (iframe.clientHeight !== data.scrollHeight || - data.scrollHeight !== data.clientHeight) { - - // Reset iframe height, in case content has shrinked. - iframe.style.height = data.clientHeight + 'px'; - respond('resizePrepared'); - } - }; - - /** - * Resize parent and iframe to desired height. - * - * @private - * @param {Object} iframe Element - * @param {Object} data Payload - * @param {Function} respond Send a response to the iframe - */ - actionHandlers.resize = function (iframe, data) { - // Resize iframe so all content is visible. Use scrollHeight to make sure we get everything - iframe.style.height = data.scrollHeight + 'px'; - }; - - /** - * Keyup event handler. Exits full screen on escape. - * - * @param {Event} event - */ - var escape = function (event) { - if (event.keyCode === 27) { - exitFullScreen(); - } - }; - - // Listen for messages from iframes - window.addEventListener('message', function receiveMessage(event) { - if (event.data.context !== 'h5p') { - return; // Only handle h5p requests. - } - - // Find out who sent the message - var iframe, iframes = document.getElementsByTagName('iframe'); - for (var i = 0; i < iframes.length; i++) { - if (iframes[i].contentWindow === event.source) { - iframe = iframes[i]; - break; - } - } - - if (!iframe) { - return; // Cannot find sender - } - - // Find action handler handler - if (actionHandlers[event.data.action]) { - actionHandlers[event.data.action](iframe, event.data, function respond(action, data) { - if (data === undefined) { - data = {}; - } - data.action = action; - data.context = 'h5p'; - event.source.postMessage(data, event.origin); - }); - } - }, false); - - // Let h5p iframes know we're ready! - var iframes = document.getElementsByTagName('iframe'); - var ready = { - context: 'h5p', - action: 'ready' - }; - for (var i = 0; i < iframes.length; i++) { - if (iframes[i].src.indexOf('h5p') !== -1) { - iframes[i].contentWindow.postMessage(ready, '*'); - } - } - -})(); diff --git a/apps/server/static-assets/h5p/core/js/h5p-utils.js b/apps/server/static-assets/h5p/core/js/h5p-utils.js deleted file mode 100644 index b5aa3334e0e..00000000000 --- a/apps/server/static-assets/h5p/core/js/h5p-utils.js +++ /dev/null @@ -1,506 +0,0 @@ -/* global H5PAdminIntegration*/ -var H5PUtils = H5PUtils || {}; - -(function ($) { - /** - * Generic function for creating a table including the headers - * - * @param {array} headers List of headers - */ - H5PUtils.createTable = function (headers) { - var $table = $('
      '); - - if (headers) { - var $thead = $(''); - var $tr = $(''); - - $.each(headers, function (index, value) { - if (!(value instanceof Object)) { - value = { - html: value - }; - } - - $('', value).appendTo($tr); - }); - - $table.append($thead.append($tr)); - } - - return $table; - }; - - /** - * Generic function for creating a table row - * - * @param {array} rows Value list. Object name is used as class name in - */ - H5PUtils.createTableRow = function (rows) { - var $tr = $(''); - - $.each(rows, function (index, value) { - if (!(value instanceof Object)) { - value = { - html: value - }; - } - - $('', value).appendTo($tr); - }); - - return $tr; - }; - - /** - * Generic function for creating a field containing label and value - * - * @param {string} label The label displayed in front of the value - * @param {string} value The value - */ - H5PUtils.createLabeledField = function (label, value) { - var $field = $('
      '); - - $field.append('
      ' + label + '
      '); - $field.append('
      ' + value + '
      '); - - return $field; - }; - - /** - * Replaces placeholder fields in translation strings - * - * @param {string} template The translation template string in the following format: "$name is a $sex" - * @param {array} replacors An js object with key and values. Eg: {'$name': 'Frode', '$sex': 'male'} - */ - H5PUtils.translateReplace = function (template, replacors) { - $.each(replacors, function (key, value) { - template = template.replace(new RegExp('\\'+key, 'g'), value); - }); - return template; - }; - - /** - * Get throbber with given text. - * - * @param {String} text - * @returns {$} - */ - H5PUtils.throbber = function (text) { - return $('
      ', { - class: 'h5p-throbber', - text: text - }); - }; - - /** - * Makes it possbile to rebuild all content caches from admin UI. - * @param {Object} notCached - * @returns {$} - */ - H5PUtils.getRebuildCache = function (notCached) { - var $container = $('

      ' + notCached.message + '

      ' + notCached.progress + '

      '); - var $button = $('').appendTo($container).click(function () { - var $spinner = $('
      ', {class: 'h5p-spinner'}).replaceAll($button); - var parts = ['|', '/', '-', '\\']; - var current = 0; - var spinning = setInterval(function () { - $spinner.text(parts[current]); - current++; - if (current === parts.length) current = 0; - }, 100); - - var $counter = $container.find('.progress'); - var build = function () { - $.post(notCached.url, function (left) { - if (left === '0') { - clearInterval(spinning); - $container.remove(); - location.reload(); - } - else { - var counter = $counter.text().split(' '); - counter[0] = left; - $counter.text(counter.join(' ')); - build(); - } - }); - }; - build(); - }); - - return $container; - }; - - /** - * Generic table class with useful helpers. - * - * @class - * @param {Object} classes - * Custom html classes to use on elements. - * e.g. {tableClass: 'fixed'}. - */ - H5PUtils.Table = function (classes) { - var numCols; - var sortByCol; - var $sortCol; - var sortCol; - var sortDir; - - // Create basic table - var tableOptions = {}; - if (classes.table !== undefined) { - tableOptions['class'] = classes.table; - } - var $table = $('', tableOptions); - var $thead = $('').appendTo($table); - var $tfoot = $('').appendTo($table); - var $tbody = $('').appendTo($table); - - /** - * Add columns to given table row. - * - * @private - * @param {jQuery} $tr Table row - * @param {(String|Object)} col Column properties - * @param {Number} id Used to seperate the columns - */ - var addCol = function ($tr, col, id) { - var options = { - on: {} - }; - - if (!(col instanceof Object)) { - options.text = col; - } - else { - if (col.text !== undefined) { - options.text = col.text; - } - if (col.class !== undefined) { - options.class = col.class; - } - - if (sortByCol !== undefined && col.sortable === true) { - // Make sortable - options.role = 'button'; - options.tabIndex = 0; - - // This is the first sortable column, use as default sort - if (sortCol === undefined) { - sortCol = id; - sortDir = 0; - } - - // This is the sort column - if (sortCol === id) { - options['class'] = 'h5p-sort'; - if (sortDir === 1) { - options['class'] += ' h5p-reverse'; - } - } - - options.on.click = function () { - sort($th, id); - }; - options.on.keypress = function (event) { - if ((event.charCode || event.keyCode) === 32) { // Space - sort($th, id); - } - }; - } - } - - // Append - var $th = $(''); - var $tr = $('').appendTo($newThead); - for (var i = 0; i < cols.length; i++) { - addCol($tr, cols[i], i); - } - - // Update DOM - $thead.replaceWith($newThead); - $thead = $newThead; - }; - - /** - * Set table rows. - * - * @public - * @param {Array} rows Table rows with cols: [[1,'hello',3],[2,'asd',6]] - */ - this.setRows = function (rows) { - var $newTbody = $(''); - - for (var i = 0; i < rows.length; i++) { - var $tr = $('').appendTo($newTbody); - - for (var j = 0; j < rows[i].length; j++) { - $(''); - var $tr = $('').appendTo($newTbody); - $(''); - var $tr = $('').appendTo($newTfoot); - $('\s*$/g,At={option:[1,""],legend:[1,"
      ","
      "],area:[1,"",""],param:[1,"",""],thead:[1,"
      ', options).appendTo($tr); - if (sortCol === id) { - $sortCol = $th; // Default sort column - } - }; - - /** - * Updates the UI when a column header has been clicked. - * Triggers sorting callback. - * - * @private - * @param {jQuery} $th Table header - * @param {Number} id Used to seperate the columns - */ - var sort = function ($th, id) { - if (id === sortCol) { - // Change sorting direction - if (sortDir === 0) { - sortDir = 1; - $th.addClass('h5p-reverse'); - } - else { - sortDir = 0; - $th.removeClass('h5p-reverse'); - } - } - else { - // Change sorting column - $sortCol.removeClass('h5p-sort').removeClass('h5p-reverse'); - $sortCol = $th.addClass('h5p-sort'); - sortCol = id; - sortDir = 0; - } - - sortByCol({ - by: sortCol, - dir: sortDir - }); - }; - - /** - * Set table headers. - * - * @public - * @param {Array} cols - * Table header data. Can be strings or objects with options like - * "text" and "sortable". E.g. - * [{text: 'Col 1', sortable: true}, 'Col 2', 'Col 3'] - * @param {Function} sort Callback which is runned when sorting changes - * @param {Object} [order] - */ - this.setHeaders = function (cols, sort, order) { - numCols = cols.length; - sortByCol = sort; - - if (order) { - sortCol = order.by; - sortDir = order.dir; - } - - // Create new head - var $newThead = $('
      ', { - html: rows[i][j] - }).appendTo($tr); - } - } - - $tbody.replaceWith($newTbody); - $tbody = $newTbody; - - return $tbody; - }; - - /** - * Set custom table body content. This can be a message or a throbber. - * Will cover all table columns. - * - * @public - * @param {jQuery} $content Custom content - */ - this.setBody = function ($content) { - var $newTbody = $('
      ', { - colspan: numCols - }).append($content).appendTo($tr); - $tbody.replaceWith($newTbody); - $tbody = $newTbody; - }; - - /** - * Set custom table foot content. This can be a pagination widget. - * Will cover all table columns. - * - * @public - * @param {jQuery} $content Custom content - */ - this.setFoot = function ($content) { - var $newTfoot = $('
      ', { - colspan: numCols - }).append($content).appendTo($tr); - $tfoot.replaceWith($newTfoot); - }; - - - /** - * Appends the table to the given container. - * - * @public - * @param {jQuery} $container - */ - this.appendTo = function ($container) { - $table.appendTo($container); - }; - }; - - /** - * Generic pagination class. Creates a useful pagination widget. - * - * @class - * @param {Number} num Total number of items to pagiate. - * @param {Number} limit Number of items to dispaly per page. - * @param {Function} goneTo - * Callback which is fired when the user wants to go to another page. - * @param {Object} l10n - * Localization / translations. e.g. - * { - * currentPage: 'Page $current of $total', - * nextPage: 'Next page', - * previousPage: 'Previous page' - * } - */ - H5PUtils.Pagination = function (num, limit, goneTo, l10n) { - var current = 0; - var pages = Math.ceil(num / limit); - - // Create components - - // Previous button - var $left = $(''; - } - if (contentData.displayOptions.export && contentData.displayOptions.copy) { - html += '
      or
      '; - } - if (contentData.displayOptions.copy) { - html += ''; - } - - const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); - - // Selecting embed code when dialog is opened - H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { - H5P.jQuery('More Info').click(function (e) { - e.stopPropagation(); - }).appendTo($dialog.find('h2')); - $dialog.find('.h5p-download-button').click(function () { - window.location.href = contentData.exportUrl; - instance.triggerXAPI('downloaded'); - dialog.close(); - }); - $dialog.find('.h5p-copy-button').click(function () { - const item = new H5P.ClipboardItem(library); - item.contentId = contentId; - H5P.setClipboard(item); - instance.triggerXAPI('copied'); - dialog.close(); - H5P.attachToastTo( - H5P.jQuery('.h5p-content:first')[0], - H5P.t('contentCopied'), - { - position: { - horizontal: 'centered', - vertical: 'centered', - noOverflowX: true - } - } - ); - }); - H5P.trigger(instance, 'resize'); - }).on('dialog-closed', function () { - H5P.trigger(instance, 'resize'); - }); - - dialog.open(); -}; - -/** - * Display a dialog containing the embed code. - * - * @param {H5P.jQuery} $element - * Element to insert dialog after. - * @param {string} embedCode - * The embed code. - * @param {string} resizeCode - * The advanced resize code - * @param {Object} size - * The content's size. - * @param {number} size.width - * @param {number} size.height - */ -H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { - var fullEmbedCode = embedCode + resizeCode; - var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
      ' + H5P.t('showAdvanced') + '

      ' + H5P.t('advancedHelp') + '

      ', $element); - - // Selecting embed code when dialog is opened - H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { - var $inner = $dialog.find('.h5p-inner'); - var $scroll = $inner.find('.h5p-scroll-content'); - var diff = $scroll.outerHeight() - $scroll.innerHeight(); - var positionInner = function () { - H5P.trigger(instance, 'resize'); - }; - - // Handle changing of width/height - var $w = $dialog.find('.h5p-embed-size:eq(0)'); - var $h = $dialog.find('.h5p-embed-size:eq(1)'); - var getNum = function ($e, d) { - var num = parseFloat($e.val()); - if (isNaN(num)) { - return d; - } - return Math.ceil(num); - }; - var updateEmbed = function () { - $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); - }; - - $w.change(updateEmbed); - $h.change(updateEmbed); - updateEmbed(); - - // Select text and expand textareas - $dialog.find('.h5p-embed-code-container').each(function () { - H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { - H5P.jQuery(this).select(); - }); - }); - $dialog.find('.h5p-embed-code-container').eq(0).select(); - positionInner(); - - // Expand advanced embed - var expand = function () { - var $expander = H5P.jQuery(this); - var $content = $expander.next(); - if ($content.is(':visible')) { - $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')).attr('aria-expanded', 'true'); - $content.hide(); - } - else { - $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')).attr('aria-expanded', 'false'); - $content.show(); - } - $dialog.find('.h5p-embed-code-container').each(function () { - H5P.jQuery(this).css('height', this.scrollHeight + 'px'); - }); - positionInner(); - }; - $dialog.find('.h5p-expander').click(expand).keypress(function (event) { - if (event.keyCode === 32) { - expand.apply(this); - return false; - } - }); - }).on('dialog-closed', function () { - H5P.trigger(instance, 'resize'); - }); - - dialog.open(); -}; - -/** - * Show a toast message. - * - * The reference element could be dom elements the toast should be attached to, - * or e.g. the document body for general toast messages. - * - * @param {DOM} element Reference element to show toast message for. - * @param {string} message Message to show. - * @param {object} [config] Configuration. - * @param {string} [config.style=h5p-toast] Style name for the tooltip. - * @param {number} [config.duration=3000] Toast message length in ms. - * @param {object} [config.position] Relative positioning of the toast. - * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. - * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. - * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. - * @param {number} [config.position.offsetVertical=0] Extra vetical offset. - * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. - * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. - * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. - * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. - * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. - * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. - * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. - */ -H5P.attachToastTo = function (element, message, config) { - if (element === undefined || message === undefined) { - return; - } - - const eventPath = function (evt) { - var path = (evt.composedPath && evt.composedPath()) || evt.path; - var target = evt.target; - - if (path != null) { - // Safari doesn't include Window, but it should. - return (path.indexOf(window) < 0) ? path.concat(window) : path; - } - - if (target === window) { - return [window]; - } - - function getParents(node, memo) { - memo = memo || []; - var parentNode = node.parentNode; - - if (!parentNode) { - return memo; - } - else { - return getParents(parentNode, memo.concat(parentNode)); - } - } - - return [target].concat(getParents(target), window); - }; - - /** - * Handle click while toast is showing. - */ - const clickHandler = function (event) { - /* - * A common use case will be to attach toasts to buttons that are clicked. - * The click would remove the toast message instantly without this check. - * Children of the clicked element are also ignored. - */ - var path = eventPath(event); - if (path.indexOf(element) !== -1) { - return; - } - clearTimeout(timer); - removeToast(); - }; - - - - /** - * Remove the toast message. - */ - const removeToast = function () { - document.removeEventListener('click', clickHandler); - if (toast.parentNode) { - toast.parentNode.removeChild(toast); - } - }; - - /** - * Get absolute coordinates for the toast. - * - * @param {DOM} element Reference element to show toast message for. - * @param {DOM} toast Toast element. - * @param {object} [position={}] Relative positioning of the toast message. - * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. - * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. - * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. - * @param {number} [position.offsetVertical=0] Extra vetical offset. - * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. - * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. - * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. - * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. - * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. - * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. - * @return {object} - */ - const getToastCoordinates = function (element, toast, position) { - position = position || {}; - position.offsetHorizontal = position.offsetHorizontal || 0; - position.offsetVertical = position.offsetVertical || 0; - - const toastRect = toast.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - - let left = 0; - let top = 0; - - // Compute horizontal position - switch (position.horizontal) { - case 'before': - left = elementRect.left - toastRect.width - position.offsetHorizontal; - break; - case 'after': - left = elementRect.left + elementRect.width + position.offsetHorizontal; - break; - case 'left': - left = elementRect.left + position.offsetHorizontal; - break; - case 'right': - left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; - break; - case 'centered': - left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; - break; - default: - left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; - } - - // Compute vertical position - switch (position.vertical) { - case 'above': - top = elementRect.top - toastRect.height - position.offsetVertical; - break; - case 'below': - top = elementRect.top + elementRect.height + position.offsetVertical; - break; - case 'top': - top = elementRect.top + position.offsetVertical; - break; - case 'bottom': - top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; - break; - case 'centered': - top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; - break; - default: - top = elementRect.top + elementRect.height + position.offsetVertical; - } - - // Prevent overflow - const overflowElement = document.body; - const bounds = overflowElement.getBoundingClientRect(); - if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { - left = bounds.x; - } - if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { - left = bounds.x + bounds.width - toastRect.width; - } - if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { - top = bounds.y; - } - if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { - left = bounds.y + bounds.height - toastRect.height; - } - - return {left: left, top: top}; - }; - - // Sanitization - config = config || {}; - config.style = config.style || 'h5p-toast'; - config.duration = config.duration || 3000; - - // Build toast - const toast = document.createElement('div'); - toast.setAttribute('id', config.style); - toast.classList.add('h5p-toast-disabled'); - toast.classList.add(config.style); - - const msg = document.createElement('span'); - msg.innerHTML = message; - toast.appendChild(msg); - - document.body.appendChild(toast); - - // The message has to be set before getting the coordinates - const coordinates = getToastCoordinates(element, toast, config.position); - toast.style.left = Math.round(coordinates.left) + 'px'; - toast.style.top = Math.round(coordinates.top) + 'px'; - - toast.classList.remove('h5p-toast-disabled'); - const timer = setTimeout(removeToast, config.duration); - - // The toast can also be removed by clicking somewhere - document.addEventListener('click', clickHandler); -}; - -/** - * Copyrights for a H5P Content Library. - * - * @class - */ -H5P.ContentCopyrights = function () { - var label; - var media = []; - var content = []; - - /** - * Set label. - * - * @param {string} newLabel - */ - this.setLabel = function (newLabel) { - label = newLabel; - }; - - /** - * Add sub content. - * - * @param {H5P.MediaCopyright} newMedia - */ - this.addMedia = function (newMedia) { - if (newMedia !== undefined) { - media.push(newMedia); - } - }; - - /** - * Add sub content in front. - * - * @param {H5P.MediaCopyright} newMedia - */ - this.addMediaInFront = function (newMedia) { - if (newMedia !== undefined) { - media.unshift(newMedia); - } - }; - - /** - * Add sub content. - * - * @param {H5P.ContentCopyrights} newContent - */ - this.addContent = function (newContent) { - if (newContent !== undefined) { - content.push(newContent); - } - }; - - /** - * Print content copyright. - * - * @returns {string} HTML. - */ - this.toString = function () { - var html = ''; - - // Add media rights - for (var i = 0; i < media.length; i++) { - html += media[i]; - } - - // Add sub content rights - for (i = 0; i < content.length; i++) { - html += content[i]; - } - - - if (html !== '') { - // Add a label to this info - if (label !== undefined) { - html = '

      ' + label + '

      ' + html; - } - - // Add wrapper - html = '
      ' + html + '
      '; - } - - return html; - }; -}; - -/** - * A ordered list of copyright fields for media. - * - * @class - * @param {Object} copyright - * Copyright information fields. - * @param {Object} [labels] - * Translation of labels. - * @param {Array} [order] - * Order of the fields. - * @param {Object} [extraFields] - * Add extra copyright fields. - */ -H5P.MediaCopyright = function (copyright, labels, order, extraFields) { - var thumbnail; - var list = new H5P.DefinitionList(); - - /** - * Get translated label for field. - * - * @private - * @param {string} fieldName - * @returns {string} - */ - var getLabel = function (fieldName) { - if (labels === undefined || labels[fieldName] === undefined) { - return H5P.t(fieldName); - } - - return labels[fieldName]; - }; - - /** - * Get humanized value for the license field. - * - * @private - * @param {string} license - * @param {string} [version] - * @returns {string} - */ - var humanizeLicense = function (license, version) { - var copyrightLicense = H5P.copyrightLicenses[license]; - - // Build license string - var value = ''; - if (!(license === 'PD' && version)) { - // Add license label - value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); - } - - // Check for version info - var versionInfo; - if (copyrightLicense.versions) { - if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { - version = copyrightLicense.versions.default; - } - if (version && copyrightLicense.versions[version]) { - versionInfo = copyrightLicense.versions[version]; - } - } - - if (versionInfo) { - // Add license version - if (value) { - value += ' '; - } - value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); - } - - // Add link if specified - var link; - if (copyrightLicense.hasOwnProperty('link')) { - link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); - } - else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { - link = versionInfo.link; - } - if (link) { - value = '' + value + ''; - } - - // Generate parenthesis - var parenthesis = ''; - if (license !== 'PD' && license !== 'C') { - parenthesis += license; - } - if (version && version !== 'CC0 1.0') { - if (parenthesis && license !== 'GNU GPL') { - parenthesis += ' '; - } - parenthesis += version; - } - if (parenthesis) { - value += ' (' + parenthesis + ')'; - } - if (license === 'C') { - value += ' ©'; - } - - return value; - }; - - if (copyright !== undefined) { - // Add the extra fields - for (var field in extraFields) { - if (extraFields.hasOwnProperty(field)) { - copyright[field] = extraFields[field]; - } - } - - if (order === undefined) { - // Set default order - order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; - } - - for (var i = 0; i < order.length; i++) { - var fieldName = order[i]; - if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { - var humanValue = copyright[fieldName]; - if (fieldName === 'license') { - humanValue = humanizeLicense(copyright.license, copyright.version); - } - if (fieldName === 'source') { - humanValue = (humanValue) ? '' + humanValue + '' : undefined; - } - list.add(new H5P.Field(getLabel(fieldName), humanValue)); - } - } - } - - /** - * Set thumbnail. - * - * @param {H5P.Thumbnail} newThumbnail - */ - this.setThumbnail = function (newThumbnail) { - thumbnail = newThumbnail; - }; - - /** - * Checks if this copyright is undisclosed. - * I.e. only has the license attribute set, and it's undisclosed. - * - * @returns {boolean} - */ - this.undisclosed = function () { - if (list.size() === 1) { - var field = list.get(0); - if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { - return true; - } - } - return false; - }; - - /** - * Print media copyright. - * - * @returns {string} HTML. - */ - this.toString = function () { - var html = ''; - - if (this.undisclosed()) { - return html; // No need to print a copyright with a single undisclosed license. - } - - if (thumbnail !== undefined) { - html += thumbnail; - } - html += list; - - if (html !== '') { - html = ''; - } - - return html; - }; -}; - -/** - * A simple and elegant class for creating thumbnails of images. - * - * @class - * @param {string} source - * @param {number} width - * @param {number} height - */ -H5P.Thumbnail = function (source, width, height) { - var thumbWidth, thumbHeight = 100; - if (width !== undefined) { - thumbWidth = Math.round(thumbHeight * (width / height)); - } - - /** - * Print thumbnail. - * - * @returns {string} HTML. - */ - this.toString = function () { - return '' + H5P.t('thumbnail') + ''; - }; -}; - -/** - * Simple data structure class for storing a single field. - * - * @class - * @param {string} label - * @param {string} value - */ -H5P.Field = function (label, value) { - /** - * Public. Get field label. - * - * @returns {String} - */ - this.getLabel = function () { - return label; - }; - - /** - * Public. Get field value. - * - * @returns {String} - */ - this.getValue = function () { - return value; - }; -}; - -/** - * Simple class for creating a definition list. - * - * @class - */ -H5P.DefinitionList = function () { - var fields = []; - - /** - * Add field to list. - * - * @param {H5P.Field} field - */ - this.add = function (field) { - fields.push(field); - }; - - /** - * Get Number of fields. - * - * @returns {number} - */ - this.size = function () { - return fields.length; - }; - - /** - * Get field at given index. - * - * @param {number} index - * @returns {H5P.Field} - */ - this.get = function (index) { - return fields[index]; - }; - - /** - * Print definition list. - * - * @returns {string} HTML. - */ - this.toString = function () { - var html = ''; - for (var i = 0; i < fields.length; i++) { - var field = fields[i]; - html += '
      ' + field.getLabel() + '
      ' + field.getValue() + '
      '; - } - return (html === '' ? html : '
      ' + html + '
      '); - }; -}; - -/** - * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. - * - * Helper object for keeping coordinates in the same format all over. - * - * @deprecated - * Will be removed march 2016. - * @class - * @param {number} x - * @param {number} y - * @param {number} w - * @param {number} h - */ -H5P.Coords = function (x, y, w, h) { - if ( !(this instanceof H5P.Coords) ) - return new H5P.Coords(x, y, w, h); - - /** @member {number} */ - this.x = 0; - /** @member {number} */ - this.y = 0; - /** @member {number} */ - this.w = 1; - /** @member {number} */ - this.h = 1; - - if (typeof(x) === 'object') { - this.x = x.x; - this.y = x.y; - this.w = x.w; - this.h = x.h; - } - else { - if (x !== undefined) { - this.x = x; - } - if (y !== undefined) { - this.y = y; - } - if (w !== undefined) { - this.w = w; - } - if (h !== undefined) { - this.h = h; - } - } - return this; -}; - -/** - * Parse library string into values. - * - * @param {string} library - * library in the format "machineName majorVersion.minorVersion" - * @returns {Object} - * library as an object with machineName, majorVersion and minorVersion properties - * return false if the library parameter is invalid - */ -H5P.libraryFromString = function (library) { - var regExp = /(.+)\s(\d+)\.(\d+)$/g; - var res = regExp.exec(library); - if (res !== null) { - return { - 'machineName': res[1], - 'majorVersion': parseInt(res[2]), - 'minorVersion': parseInt(res[3]) - }; - } - else { - return false; - } -}; - -/** - * Get the path to the library - * - * @param {string} library - * The library identifier in the format "machineName-majorVersion.minorVersion". - * @returns {string} - * The full path to the library. - */ -H5P.getLibraryPath = function (library) { - if (H5PIntegration.urlLibraries !== undefined) { - // This is an override for those implementations that has a different libraries URL, e.g. Moodle - return H5PIntegration.urlLibraries + '/' + library; - } - else { - return H5PIntegration.url + '/libraries/' + library; - } -}; - -/** - * Recursivly clone the given object. - * - * @param {Object|Array} object - * Object to clone. - * @param {boolean} [recursive] - * @returns {Object|Array} - * A clone of object. - */ -H5P.cloneObject = function (object, recursive) { - // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? - var clone = object instanceof Array ? [] : {}; - - for (var i in object) { - if (object.hasOwnProperty(i)) { - if (recursive !== undefined && recursive && typeof object[i] === 'object') { - clone[i] = H5P.cloneObject(object[i], recursive); - } - else { - clone[i] = object[i]; - } - } - } - - return clone; -}; - -/** - * Remove all empty spaces before and after the value. - * - * @param {string} value - * @returns {string} - */ -H5P.trim = function (value) { - return value.replace(/^\s+|\s+$/g, ''); - - // TODO: Only include this or String.trim(). What is best? - // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ - // So should we make this function deprecated? -}; - -/** - * Check if JavaScript path/key is loaded. - * - * @param {string} path - * @returns {boolean} - */ -H5P.jsLoaded = function (path) { - H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; - return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; -}; - -/** - * Check if styles path/key is loaded. - * - * @param {string} path - * @returns {boolean} - */ -H5P.cssLoaded = function (path) { - H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; - return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; -}; - -/** - * Shuffle an array in place. - * - * @param {Array} array - * Array to shuffle - * @returns {Array} - * The passed array is returned for chaining. - */ -H5P.shuffleArray = function (array) { - // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. - if (!(array instanceof Array)) { - return; - } - - var i = array.length, j, tempi, tempj; - if ( i === 0 ) return false; - while ( --i ) { - j = Math.floor( Math.random() * ( i + 1 ) ); - tempi = array[i]; - tempj = array[j]; - array[i] = tempj; - array[j] = tempi; - } - return array; -}; - -/** - * Post finished results for user. - * - * @deprecated - * Do not use this function directly, trigger the finish event instead. - * Will be removed march 2016 - * @param {number} contentId - * Identifies the content - * @param {number} score - * Achieved score/points - * @param {number} maxScore - * The maximum score/points that can be achieved - * @param {number} [time] - * Reported time consumption/usage - */ -H5P.setFinished = function (contentId, score, maxScore, time) { - var validScore = typeof score === 'number' || score instanceof Number; - if (validScore && H5PIntegration.postUserStatistics === true) { - /** - * Return unix timestamp for the given JS Date. - * - * @private - * @param {Date} date - * @returns {Number} - */ - var toUnix = function (date) { - return Math.round(date.getTime() / 1000); - }; - - // Post the results - const data = { - contentId: contentId, - score: score, - maxScore: maxScore, - opened: toUnix(H5P.opened[contentId]), - finished: toUnix(new Date()), - time: time - }; - H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) - .fail(function () { - H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); - }); - } -}; - -// Add indexOf to browsers that lack them. (IEs) -if (!Array.prototype.indexOf) { - Array.prototype.indexOf = function (needle) { - for (var i = 0; i < this.length; i++) { - if (this[i] === needle) { - return i; - } - } - return -1; - }; -} - -// Need to define trim() since this is not available on older IEs, -// and trim is used in several libs -if (String.prototype.trim === undefined) { - String.prototype.trim = function () { - return H5P.trim(this); - }; -} - -/** - * Trigger an event on an instance - * - * Helper function that triggers an event if the instance supports event handling - * - * @param {Object} instance - * Instance of H5P content - * @param {string} eventType - * Type of event to trigger - * @param {*} data - * @param {Object} extras - */ -H5P.trigger = function (instance, eventType, data, extras) { - // Try new event system first - if (instance.trigger !== undefined) { - instance.trigger(eventType, data, extras); - } - // Try deprecated event system - else if (instance.$ !== undefined && instance.$.trigger !== undefined) { - instance.$.trigger(eventType); - } -}; - -/** - * Register an event handler - * - * Helper function that registers an event handler for an event type if - * the instance supports event handling - * - * @param {Object} instance - * Instance of H5P content - * @param {string} eventType - * Type of event to listen for - * @param {H5P.EventCallback} handler - * Callback that gets triggered for events of the specified type - */ -H5P.on = function (instance, eventType, handler) { - // Try new event system first - if (instance.on !== undefined) { - instance.on(eventType, handler); - } - // Try deprecated event system - else if (instance.$ !== undefined && instance.$.on !== undefined) { - instance.$.on(eventType, handler); - } -}; - -/** - * Generate random UUID - * - * @returns {string} UUID - */ -H5P.createUUID = function () { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { - var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); - return newChar.toString(16); - }); -}; - -/** - * Create title - * - * @param {string} rawTitle - * @param {number} maxLength - * @returns {string} - */ -H5P.createTitle = function (rawTitle, maxLength) { - if (!rawTitle) { - return ''; - } - if (maxLength === undefined) { - maxLength = 60; - } - var title = H5P.jQuery('
      ') - .text( - // Strip tags - rawTitle.replace(/(<([^>]+)>)/ig,"") - // Escape - ).text(); - if (title.length > maxLength) { - title = title.substr(0, maxLength - 3) + '...'; - } - return title; -}; - -// Wrap in privates -(function ($) { - - /** - * Creates ajax requests for inserting, updateing and deleteing - * content user data. - * - * @private - * @param {number} contentId What content to store the data for. - * @param {string} dataType Identifies the set of data for this content. - * @param {string} subContentId Identifies sub content - * @param {function} [done] Callback when ajax is done. - * @param {object} [data] To be stored for future use. - * @param {boolean} [preload=false] Data is loaded when content is loaded. - * @param {boolean} [invalidate=false] Data is invalidated when content changes. - * @param {boolean} [async=true] - */ - function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { - if (H5PIntegration.user === undefined) { - // Not logged in, no use in saving. - done('Not signed in.'); - return; - } - - var options = { - url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), - dataType: 'json', - async: async === undefined ? true : async - }; - if (data !== undefined) { - options.type = 'POST'; - options.data = { - data: (data === null ? 0 : data), - preload: (preload ? 1 : 0), - invalidate: (invalidate ? 1 : 0) - }; - } - else { - options.type = 'GET'; - } - if (done !== undefined) { - options.error = function (xhr, error) { - done(error); - }; - options.success = function (response) { - if (!response.success) { - done(response.message); - return; - } - - if (response.data === false || response.data === undefined) { - done(); - return; - } - - done(undefined, response.data); - }; - } - - $.ajax(options); - } - - /** - * Get user data for given content. - * - * @param {number} contentId - * What content to get data for. - * @param {string} dataId - * Identifies the set of data for this content. - * @param {function} done - * Callback with error and data parameters. - * @param {string} [subContentId] - * Identifies which data belongs to sub content. - */ - H5P.getUserData = function (contentId, dataId, done, subContentId) { - if (!subContentId) { - subContentId = 0; // Default - } - - H5PIntegration.contents = H5PIntegration.contents || {}; - var content = H5PIntegration.contents['cid-' + contentId] || {}; - var preloadedData = content.contentUserData; - if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { - if (preloadedData[subContentId][dataId] === 'RESET') { - done(undefined, null); - return; - } - try { - done(undefined, JSON.parse(preloadedData[subContentId][dataId])); - } - catch (err) { - done(err); - } - } - else { - contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { - if (err || data === undefined) { - done(err, data); - return; // Error or no data - } - - // Cache in preloaded - if (content.contentUserData === undefined) { - content.contentUserData = preloadedData = {}; - } - if (preloadedData[subContentId] === undefined) { - preloadedData[subContentId] = {}; - } - preloadedData[subContentId][dataId] = data; - - // Done. Try to decode JSON - try { - done(undefined, JSON.parse(data)); - } - catch (e) { - done(e); - } - }); - } - }; - - /** - * Async error handling. - * - * @callback H5P.ErrorCallback - * @param {*} error - */ - - /** - * Set user data for given content. - * - * @param {number} contentId - * What content to get data for. - * @param {string} dataId - * Identifies the set of data for this content. - * @param {Object} data - * The data that is to be stored. - * @param {Object} [extras] - * Extra properties - * @param {string} [extras.subContentId] - * Identifies which data belongs to sub content. - * @param {boolean} [extras.preloaded=true] - * If the data should be loaded when content is loaded. - * @param {boolean} [extras.deleteOnChange=false] - * If the data should be invalidated when the content changes. - * @param {H5P.ErrorCallback} [extras.errorCallback] - * Callback with error as parameters. - * @param {boolean} [extras.async=true] - */ - H5P.setUserData = function (contentId, dataId, data, extras) { - var options = H5P.jQuery.extend(true, {}, { - subContentId: 0, - preloaded: true, - deleteOnChange: false, - async: true - }, extras); - - try { - data = JSON.stringify(data); - } - catch (err) { - if (options.errorCallback) { - options.errorCallback(err); - } - return; // Failed to serialize. - } - - var content = H5PIntegration.contents['cid-' + contentId]; - if (content === undefined) { - content = H5PIntegration.contents['cid-' + contentId] = {}; - } - if (!content.contentUserData) { - content.contentUserData = {}; - } - var preloadedData = content.contentUserData; - if (preloadedData[options.subContentId] === undefined) { - preloadedData[options.subContentId] = {}; - } - if (data === preloadedData[options.subContentId][dataId]) { - return; // No need to save this twice. - } - - preloadedData[options.subContentId][dataId] = data; - contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { - if (options.errorCallback && error) { - options.errorCallback(error); - } - }, data, options.preloaded, options.deleteOnChange, options.async); - }; - - /** - * Delete user data for given content. - * - * @param {number} contentId - * What content to remove data for. - * @param {string} dataId - * Identifies the set of data for this content. - * @param {string} [subContentId] - * Identifies which data belongs to sub content. - */ - H5P.deleteUserData = function (contentId, dataId, subContentId) { - if (!subContentId) { - subContentId = 0; // Default - } - - // Remove from preloaded/cache - var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; - if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { - delete preloadedData[subContentId][dataId]; - } - - contentUserDataAjax(contentId, dataId, subContentId, undefined, null); - }; - - /** - * Function for getting content for a certain ID - * - * @param {number} contentId - * @return {Object} - */ - H5P.getContentForInstance = function (contentId) { - var key = 'cid-' + contentId; - var exists = H5PIntegration && H5PIntegration.contents && - H5PIntegration.contents[key]; - - return exists ? H5PIntegration.contents[key] : undefined; - }; - - /** - * Prepares the content parameters for storing in the clipboard. - * - * @class - * @param {Object} parameters The parameters for the content to store - * @param {string} [genericProperty] If only part of the parameters are generic, which part - * @param {string} [specificKey] If the parameters are specific, what content type does it fit - * @returns {Object} Ready for the clipboard - */ - H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { - var self = this; - - /** - * Set relative dimensions when params contains a file with a width and a height. - * Very useful to be compatible with wysiwyg editors. - * - * @private - */ - var setDimensionsFromFile = function () { - if (!self.generic) { - return; - } - var params = self.specific[self.generic]; - if (!params.params.file || !params.params.file.width || !params.params.file.height) { - return; - } - - self.width = 20; // % - self.height = (params.params.file.height / params.params.file.width) * self.width; - }; - - if (!genericProperty) { - genericProperty = 'action'; - parameters = { - action: parameters - }; - } - - self.specific = parameters; - - if (genericProperty && parameters[genericProperty]) { - self.generic = genericProperty; - } - if (specificKey) { - self.from = specificKey; - } - - if (window.H5PEditor && H5PEditor.contentId) { - self.contentId = H5PEditor.contentId; - } - - if (!self.specific.width && !self.specific.height) { - setDimensionsFromFile(); - } - }; - - /** - * Store item in the H5P Clipboard. - * - * @param {H5P.ClipboardItem|*} clipboardItem - */ - H5P.clipboardify = function (clipboardItem) { - if (!(clipboardItem instanceof H5P.ClipboardItem)) { - clipboardItem = new H5P.ClipboardItem(clipboardItem); - } - H5P.setClipboard(clipboardItem); - }; - - /** - * Retrieve parsed clipboard data. - * - * @return {Object} - */ - H5P.getClipboard = function () { - return parseClipboard(); - }; - - /** - * Set item in the H5P Clipboard. - * - * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. - */ - H5P.setClipboard = function (clipboardItem) { - localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); - - // Trigger an event so all 'Paste' buttons may be enabled. - H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); - }; - - /** - * Get config for a library - * - * @param string machineName - * @return Object - */ - H5P.getLibraryConfig = function (machineName) { - var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; - return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; - }; - - /** - * Get item from the H5P Clipboard. - * - * @private - * @return {Object} - */ - var parseClipboard = function () { - var clipboardData = localStorage.getItem('h5pClipboard'); - if (!clipboardData) { - return; - } - - // Try to parse clipboard dat - try { - clipboardData = JSON.parse(clipboardData); - } - catch (err) { - console.error('Unable to parse JSON from clipboard.', err); - return; - } - - // Update file URLs and reset content Ids - recursiveUpdate(clipboardData.specific, function (path) { - var isTmpFile = (path.substr(-4, 4) === '#tmp'); - if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { - // Comes from existing content - - if (H5PEditor.contentId) { - // .. to existing content - return '../' + clipboardData.contentId + '/' + path; - } - else { - // .. to new content - return (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/' + path; - } - } - return path; // Will automatically be looked for in tmp folder - }); - - - if (clipboardData.generic) { - // Use reference instead of key - clipboardData.generic = clipboardData.specific[clipboardData.generic]; - } - - return clipboardData; - }; - - /** - * Update file URLs and reset content IDs. - * Useful when copying content. - * - * @private - * @param {object} params Reference - * @param {function} handler Modifies the path to work when pasted - */ - var recursiveUpdate = function (params, handler) { - for (var prop in params) { - if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { - var obj = params[prop]; - if (obj.path !== undefined && obj.mime !== undefined) { - obj.path = handler(obj.path); - } - else { - if (obj.library !== undefined && obj.subContentId !== undefined) { - // Avoid multiple content with same ID - delete obj.subContentId; - } - recursiveUpdate(obj, handler); - } - } - } - }; - - // Init H5P when page is fully loadded - $(document).ready(function () { - - window.addEventListener('storage', function (event) { - // Pick up clipboard changes from other tabs - if (event.key === 'h5pClipboard') { - // Trigger an event so all 'Paste' buttons may be enabled. - H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); - } - }); - - var ccVersions = { - 'default': '4.0', - '4.0': H5P.t('licenseCC40'), - '3.0': H5P.t('licenseCC30'), - '2.5': H5P.t('licenseCC25'), - '2.0': H5P.t('licenseCC20'), - '1.0': H5P.t('licenseCC10'), - }; - - /** - * Maps copyright license codes to their human readable counterpart. - * - * @type {Object} - */ - H5P.copyrightLicenses = { - 'U': H5P.t('licenseU'), - 'CC BY': { - label: H5P.t('licenseCCBY'), - link: 'http://creativecommons.org/licenses/by/:version', - versions: ccVersions - }, - 'CC BY-SA': { - label: H5P.t('licenseCCBYSA'), - link: 'http://creativecommons.org/licenses/by-sa/:version', - versions: ccVersions - }, - 'CC BY-ND': { - label: H5P.t('licenseCCBYND'), - link: 'http://creativecommons.org/licenses/by-nd/:version', - versions: ccVersions - }, - 'CC BY-NC': { - label: H5P.t('licenseCCBYNC'), - link: 'http://creativecommons.org/licenses/by-nc/:version', - versions: ccVersions - }, - 'CC BY-NC-SA': { - label: H5P.t('licenseCCBYNCSA'), - link: 'http://creativecommons.org/licenses/by-nc-sa/:version', - versions: ccVersions - }, - 'CC BY-NC-ND': { - label: H5P.t('licenseCCBYNCND'), - link: 'http://creativecommons.org/licenses/by-nc-nd/:version', - versions: ccVersions - }, - 'CC0 1.0': { - label: H5P.t('licenseCC010'), - link: 'https://creativecommons.org/publicdomain/zero/1.0/' - }, - 'GNU GPL': { - label: H5P.t('licenseGPL'), - link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', - linkVersions: { - 'v3': '3.0', - 'v2': '2.0', - 'v1': '1.0' - }, - versions: { - 'default': 'v3', - 'v3': H5P.t('licenseV3'), - 'v2': H5P.t('licenseV2'), - 'v1': H5P.t('licenseV1') - } - }, - 'PD': { - label: H5P.t('licensePD'), - versions: { - 'CC0 1.0': { - label: H5P.t('licenseCC010'), - link: 'https://creativecommons.org/publicdomain/zero/1.0/' - }, - 'CC PDM': { - label: H5P.t('licensePDM'), - link: 'https://creativecommons.org/publicdomain/mark/1.0/' - } - } - }, - 'ODC PDDL': 'Public Domain Dedication and Licence', - 'CC PDM': { - label: H5P.t('licensePDM'), - link: 'https://creativecommons.org/publicdomain/mark/1.0/' - }, - 'C': H5P.t('licenseC'), - }; - - /** - * Indicates if H5P is embedded on an external page using iframe. - * @member {boolean} H5P.externalEmbed - */ - - // Relay events to top window. This must be done before H5P.init - // since events may be fired on initialization. - if (H5P.isFramed && H5P.externalEmbed === false) { - H5P.externalDispatcher.on('*', function (event) { - window.parent.H5P.externalDispatcher.trigger.call(this, event); - }); - } - - /** - * Prevent H5P Core from initializing. Must be overriden before document ready. - * @member {boolean} H5P.preventInit - */ - if (!H5P.preventInit) { - // Note that this start script has to be an external resource for it to - // load in correct order in IE9. - H5P.init(document.body); - } - - if (H5PIntegration.saveFreq !== false) { - // When was the last state stored - var lastStoredOn = 0; - // Store the current state of the H5P when leaving the page. - var storeCurrentState = function () { - // Make sure at least 250 ms has passed since last save - var currentTime = new Date().getTime(); - if (currentTime - lastStoredOn > 250) { - lastStoredOn = currentTime; - for (var i = 0; i < H5P.instances.length; i++) { - var instance = H5P.instances[i]; - if (instance.getCurrentState instanceof Function || - typeof instance.getCurrentState === 'function') { - var state = instance.getCurrentState(); - if (state !== undefined) { - // Async is not used to prevent the request from being cancelled. - H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); - } - } - } - } - }; - // iPad does not support beforeunload, therefore using unload - H5P.$window.one('beforeunload unload', function () { - // Only want to do this once - H5P.$window.off('pagehide beforeunload unload'); - storeCurrentState(); - }); - // pagehide is used on iPad when tabs are switched - H5P.$window.on('pagehide', storeCurrentState); - } - }); - -})(H5P.jQuery); diff --git a/apps/server/static-assets/h5p/core/js/jquery.js b/apps/server/static-assets/h5p/core/js/jquery.js deleted file mode 100644 index a05d5568b9c..00000000000 --- a/apps/server/static-assets/h5p/core/js/jquery.js +++ /dev/null @@ -1,20 +0,0 @@ -/*! jQuery v1.9.1 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license -*/(function(e,t){var n,r,i=typeof t,o=e.document,a=e.location,s=e.jQuery,u=e.$,l={},c=[],p="1.9.1",f=c.concat,d=c.push,h=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,b=function(e,t){return new b.fn.init(e,t,r)},x=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^[\],:{}\s]*$/,E=/(?:^|:|,)(?:\s*\[)+/g,S=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,A=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,j=/^-ms-/,D=/-([\da-z])/gi,L=function(e,t){return t.toUpperCase()},H=function(e){(o.addEventListener||"load"===e.type||"complete"===o.readyState)&&(q(),b.ready())},q=function(){o.addEventListener?(o.removeEventListener("DOMContentLoaded",H,!1),e.removeEventListener("load",H,!1)):(o.detachEvent("onreadystatechange",H),e.detachEvent("onload",H))};b.fn=b.prototype={jquery:p,constructor:b,init:function(e,n,r){var i,a;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof b?n[0]:n,b.merge(this,b.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:o,!0)),C.test(i[1])&&b.isPlainObject(n))for(i in n)b.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(a=o.getElementById(i[2]),a&&a.parentNode){if(a.id!==i[2])return r.find(e);this.length=1,this[0]=a}return this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):b.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),b.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return h.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=b.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return b.each(this,e,t)},ready:function(e){return b.ready.promise().done(e),this},slice:function(){return this.pushStack(h.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(b.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:d,sort:[].sort,splice:[].splice},b.fn.init.prototype=b.fn,b.extend=b.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||b.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(o=arguments[u]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(b.isPlainObject(r)||(n=b.isArray(r)))?(n?(n=!1,a=e&&b.isArray(e)?e:[]):a=e&&b.isPlainObject(e)?e:{},s[i]=b.extend(c,a,r)):r!==t&&(s[i]=r));return s},b.extend({noConflict:function(t){return e.$===b&&(e.$=u),t&&e.jQuery===b&&(e.jQuery=s),b},isReady:!1,readyWait:1,holdReady:function(e){e?b.readyWait++:b.ready(!0)},ready:function(e){if(e===!0?!--b.readyWait:!b.isReady){if(!o.body)return setTimeout(b.ready);b.isReady=!0,e!==!0&&--b.readyWait>0||(n.resolveWith(o,[b]),b.fn.trigger&&b(o).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===b.type(e)},isArray:Array.isArray||function(e){return"array"===b.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==b.type(e)||e.nodeType||b.isWindow(e))return!1;try{if(e.constructor&&!y.call(e,"constructor")&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||y.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=b.buildFragment([e],t,i),i&&b(i).remove(),b.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=b.trim(n),n&&k.test(n.replace(S,"@").replace(A,"]").replace(E,"")))?Function("return "+n)():(b.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||b.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&b.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(j,"ms-").replace(D,L)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:v&&!v.call("\ufeff\u00a0")?function(e){return null==e?"":v.call(e)}:function(e){return null==e?"":(e+"").replace(T,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?b.merge(n,"string"==typeof e?[e]:e):d.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(g)return g.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return f.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),b.isFunction(e)?(r=h.call(arguments,2),i=function(){return e.apply(n||this,r.concat(h.call(arguments)))},i.guid=e.guid=e.guid||b.guid++,i):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===b.type(r)){o=!0;for(u in r)b.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,b.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(b(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),b.ready.promise=function(t){if(!n)if(n=b.Deferred(),"complete"===o.readyState)setTimeout(b.ready);else if(o.addEventListener)o.addEventListener("DOMContentLoaded",H,!1),e.addEventListener("load",H,!1);else{o.attachEvent("onreadystatechange",H),e.attachEvent("onload",H);var r=!1;try{r=null==e.frameElement&&o.documentElement}catch(i){}r&&r.doScroll&&function a(){if(!b.isReady){try{r.doScroll("left")}catch(e){return setTimeout(a,50)}q(),b.ready()}}()}return n.promise(t)},b.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=b.type(e);return b.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=b(o);var _={};function F(e){var t=_[e]={};return b.each(e.match(w)||[],function(e,n){t[n]=!0}),t}b.Callbacks=function(e){e="string"==typeof e?_[e]||F(e):b.extend({},e);var n,r,i,o,a,s,u=[],l=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=u.length,n=!0;u&&o>a;a++)if(u[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,u&&(l?l.length&&c(l.shift()):r?u=[]:p.disable())},p={add:function(){if(u){var t=u.length;(function i(t){b.each(t,function(t,n){var r=b.type(n);"function"===r?e.unique&&p.has(n)||u.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=u.length:r&&(s=t,c(r))}return this},remove:function(){return u&&b.each(arguments,function(e,t){var r;while((r=b.inArray(t,u,r))>-1)u.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?b.inArray(e,u)>-1:!(!u||!u.length)},empty:function(){return u=[],this},disable:function(){return u=l=r=t,this},disabled:function(){return!u},lock:function(){return l=t,r||p.disable(),this},locked:function(){return!l},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!u||i&&!l||(n?l.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},b.extend({Deferred:function(e){var t=[["resolve","done",b.Callbacks("once memory"),"resolved"],["reject","fail",b.Callbacks("once memory"),"rejected"],["notify","progress",b.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return b.Deferred(function(n){b.each(t,function(t,o){var a=o[0],s=b.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&b.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?b.extend(e,r):r}},i={};return r.pipe=r.then,b.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=h.call(arguments),r=n.length,i=1!==r||e&&b.isFunction(e.promise)?r:0,o=1===i?e:b.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?h.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,u,l;if(r>1)for(s=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&b.isFunction(n[t].promise)?n[t].promise().done(a(t,l,n)).fail(o.reject).progress(a(t,u,s)):--i;return i||o.resolveWith(l,n),o.promise()}}),b.support=function(){var t,n,r,a,s,u,l,c,p,f,d=o.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="
      a",n=d.getElementsByTagName("*"),r=d.getElementsByTagName("a")[0],!n||!r||!n.length)return{};s=o.createElement("select"),l=s.appendChild(o.createElement("option")),a=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t={getSetAttribute:"t"!==d.className,leadingWhitespace:3===d.firstChild.nodeType,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/top/.test(r.getAttribute("style")),hrefNormalized:"/a"===r.getAttribute("href"),opacity:/^0.5/.test(r.style.opacity),cssFloat:!!r.style.cssFloat,checkOn:!!a.value,optSelected:l.selected,enctype:!!o.createElement("form").enctype,html5Clone:"<:nav>"!==o.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===o.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},a.checked=!0,t.noCloneChecked=a.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!l.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}a=o.createElement("input"),a.setAttribute("value",""),t.input=""===a.getAttribute("value"),a.value="t",a.setAttribute("type","radio"),t.radioValue="t"===a.value,a.setAttribute("checked","t"),a.setAttribute("name","t"),u=o.createDocumentFragment(),u.appendChild(a),t.appendChecked=a.checked,t.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;return d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip,b(function(){var n,r,a,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",u=o.getElementsByTagName("body")[0];u&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",u.appendChild(n).appendChild(d),d.innerHTML="
      t
      ",a=d.getElementsByTagName("td"),a[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===a[0].offsetHeight,a[0].style.display="",a[1].style.display="none",t.reliableHiddenOffsets=p&&0===a[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",t.boxSizing=4===d.offsetWidth,t.doesNotIncludeMarginInBodyOffset=1!==u.offsetTop,e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(o.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="
      ",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(u.style.zoom=1)),u.removeChild(n),n=d=a=r=null)}),n=s=u=l=r=a=null,t}();var O=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,B=/([A-Z])/g;function P(e,n,r,i){if(b.acceptData(e)){var o,a,s=b.expando,u="string"==typeof n,l=e.nodeType,p=l?b.cache:e,f=l?e[s]:e[s]&&s;if(f&&p[f]&&(i||p[f].data)||!u||r!==t)return f||(l?e[s]=f=c.pop()||b.guid++:f=s),p[f]||(p[f]={},l||(p[f].toJSON=b.noop)),("object"==typeof n||"function"==typeof n)&&(i?p[f]=b.extend(p[f],n):p[f].data=b.extend(p[f].data,n)),o=p[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[b.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[b.camelCase(n)])):a=o,a}}function R(e,t,n){if(b.acceptData(e)){var r,i,o,a=e.nodeType,s=a?b.cache:e,u=a?e[b.expando]:b.expando;if(s[u]){if(t&&(o=n?s[u]:s[u].data)){b.isArray(t)?t=t.concat(b.map(t,b.camelCase)):t in o?t=[t]:(t=b.camelCase(t),t=t in o?[t]:t.split(" "));for(r=0,i=t.length;i>r;r++)delete o[t[r]];if(!(n?$:b.isEmptyObject)(o))return}(n||(delete s[u].data,$(s[u])))&&(a?b.cleanData([e],!0):b.support.deleteExpando||s!=s.window?delete s[u]:s[u]=null)}}}b.extend({cache:{},expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?b.cache[e[b.expando]]:e[b.expando],!!e&&!$(e)},data:function(e,t,n){return P(e,t,n)},removeData:function(e,t){return R(e,t)},_data:function(e,t,n){return P(e,t,n,!0)},_removeData:function(e,t){return R(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&b.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),b.fn.extend({data:function(e,n){var r,i,o=this[0],a=0,s=null;if(e===t){if(this.length&&(s=b.data(o),1===o.nodeType&&!b._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>a;a++)i=r[a].name,i.indexOf("data-")||(i=b.camelCase(i.slice(5)),W(o,i,s[i]));b._data(o,"parsedAttrs",!0)}return s}return"object"==typeof e?this.each(function(){b.data(this,e)}):b.access(this,function(n){return n===t?o?W(o,e,b.data(o,e)):null:(this.each(function(){b.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){b.removeData(this,e)})}});function W(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(B,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:O.test(r)?b.parseJSON(r):r}catch(o){}b.data(e,n,r)}else r=t}return r}function $(e){var t;for(t in e)if(("data"!==t||!b.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}b.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=b._data(e,n),r&&(!i||b.isArray(r)?i=b._data(e,n,b.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=b.queue(e,t),r=n.length,i=n.shift(),o=b._queueHooks(e,t),a=function(){b.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return b._data(e,n)||b._data(e,n,{empty:b.Callbacks("once memory").add(function(){b._removeData(e,t+"queue"),b._removeData(e,n)})})}}),b.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?b.queue(this[0],e):n===t?this:this.each(function(){var t=b.queue(this,e,n);b._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&b.dequeue(this,e)})},dequeue:function(e){return this.each(function(){b.dequeue(this,e)})},delay:function(e,t){return e=b.fx?b.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=b.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=b._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var I,z,X=/[\t\r\n]/g,U=/\r/g,V=/^(?:input|select|textarea|button|object)$/i,Y=/^(?:a|area)$/i,J=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,G=/^(?:checked|selected)$/i,Q=b.support.getSetAttribute,K=b.support.input;b.fn.extend({attr:function(e,t){return b.access(this,b.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){b.removeAttr(this,e)})},prop:function(e,t){return b.access(this,b.prop,e,t,arguments.length>1)},removeProp:function(e){return e=b.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=b.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?b.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return b.isFunction(e)?this.each(function(n){b(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,a=0,s=b(this),u=t,l=e.match(w)||[];while(o=l[a++])u=r?u:!s.hasClass(o),s[u?"addClass":"removeClass"](o)}else(n===i||"boolean"===n)&&(this.className&&b._data(this,"__className__",this.className),this.className=this.className||e===!1?"":b._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(X," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=b.isFunction(e),this.each(function(n){var o,a=b(this);1===this.nodeType&&(o=i?e.call(this,n,a.val()):e,null==o?o="":"number"==typeof o?o+="":b.isArray(o)&&(o=b.map(o,function(e){return null==e?"":e+""})),r=b.valHooks[this.type]||b.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=b.valHooks[o.type]||b.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(U,""):null==n?"":n)}}}),b.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;for(;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(b.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&b.nodeName(n.parentNode,"optgroup"))){if(t=b(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=b.makeArray(t);return b(e).find("option").each(function(){this.selected=b.inArray(b(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var o,a,s,u=e.nodeType;if(e&&3!==u&&8!==u&&2!==u)return typeof e.getAttribute===i?b.prop(e,n,r):(a=1!==u||!b.isXMLDoc(e),a&&(n=n.toLowerCase(),o=b.attrHooks[n]||(J.test(n)?z:I)),r===t?o&&a&&"get"in o&&null!==(s=o.get(e,n))?s:(typeof e.getAttribute!==i&&(s=e.getAttribute(n)),null==s?t:s):null!==r?o&&a&&"set"in o&&(s=o.set(e,r,n))!==t?s:(e.setAttribute(n,r+""),r):(b.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=b.propFix[n]||n,J.test(n)?!Q&&G.test(n)?e[b.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:b.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!b.support.radioValue&&"radio"===t&&b.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!b.isXMLDoc(e),a&&(n=b.propFix[n]||n,o=b.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):V.test(e.nodeName)||Y.test(e.nodeName)&&e.href?0:t}}}}),z={get:function(e,n){var r=b.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?K&&Q?null!=i:G.test(n)?e[b.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?b.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&b.propFix[n]||n,n):e[b.camelCase("default-"+n)]=e[n]=!0,n}},K&&Q||(b.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return b.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t},set:function(e,n,r){return b.nodeName(e,"input")?(e.defaultValue=n,t):I&&I.set(e,n,r)}}),Q||(I=b.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},b.attrHooks.contenteditable={get:I.get,set:function(e,t,n){I.set(e,""===t?!1:t,n)}},b.each(["width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),b.support.hrefNormalized||(b.each(["href","src","width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),b.each(["href","src"],function(e,t){b.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),b.support.style||(b.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),b.support.optSelected||(b.propHooks.selected=b.extend(b.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),b.support.enctype||(b.propFix.enctype="encoding"),b.support.checkOn||b.each(["radio","checkbox"],function(){b.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),b.each(["radio","checkbox"],function(){b.valHooks[this]=b.extend(b.valHooks[this],{set:function(e,n){return b.isArray(n)?e.checked=b.inArray(b(e).val(),n)>=0:t}})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}b.event={global:{},add:function(e,n,r,o,a){var s,u,l,c,p,f,d,h,g,m,y,v=b._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=b.guid++),(u=v.events)||(u=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof b===i||e&&b.event.triggered===e.type?t:b.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(w)||[""],l=n.length;while(l--)s=rt.exec(n[l])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),p=b.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=b.event.special[g]||{},d=b.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&b.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=u[g])||(h=u[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),b.event.global[g]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,p,f,d,h,g,m=b.hasData(e)&&b._data(e);if(m&&(c=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(s=rt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=b.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),u=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));u&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||b.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)b.event.remove(e,d+t[l],n,r,!0);b.isEmptyObject(c)&&(delete m.handle,b._removeData(e,"events"))}},trigger:function(n,r,i,a){var s,u,l,c,p,f,d,h=[i||o],g=y.call(n,"type")?n.type:n,m=y.call(n,"namespace")?n.namespace.split("."):[];if(l=f=i=i||o,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+b.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),u=0>g.indexOf(":")&&"on"+g,n=n[b.expando]?n:new b.Event(g,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:b.makeArray(r,[n]),p=b.event.special[g]||{},a||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!a&&!p.noBubble&&!b.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(l=l.parentNode);l;l=l.parentNode)h.push(l),f=l;f===(i.ownerDocument||o)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((l=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(b._data(l,"events")||{})[n.type]&&b._data(l,"handle"),s&&s.apply(l,r),s=u&&l[u],s&&b.acceptData(l)&&s.apply&&s.apply(l,r)===!1&&n.preventDefault();if(n.type=g,!(a||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===g&&b.nodeName(i,"a")||!b.acceptData(i)||!u||!i[g]||b.isWindow(i))){f=i[u],f&&(i[u]=null),b.event.triggered=g;try{i[g]()}catch(v){}b.event.triggered=t,f&&(i[u]=f)}return n.result}},dispatch:function(e){e=b.event.fix(e);var n,r,i,o,a,s=[],u=h.call(arguments),l=(b._data(this,"events")||{})[e.type]||[],c=b.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=b.event.handlers.call(this,e,l),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((b.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,u),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(1===l.nodeType&&(l.disabled!==!0||"click"!==e.type)){for(o=[],a=0;u>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?b(r,this).index(l)>=0:b.find(r,this,null,[l]).length),o[r]&&o.push(i);o.length&&s.push({elem:l,handlers:o})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[b.expando])return e;var t,n,r,i=e.type,a=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new b.Event(a),t=r.length;while(t--)n=r[t],e[n]=a[n];return e.target||(e.target=a.srcElement||o),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,a):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,a,s=n.button,u=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||o,a=i.documentElement,r=i.body,e.pageX=n.clientX+(a&&a.scrollLeft||r&&r.scrollLeft||0)-(a&&a.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(a&&a.scrollTop||r&&r.scrollTop||0)-(a&&a.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&u&&(e.relatedTarget=u===e.target?n.toElement:u),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return b.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==o.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===o.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=b.extend(new b.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?b.event.trigger(i,null,t):b.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},b.removeEvent=o.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},b.Event=function(e,n){return this instanceof b.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&b.extend(this,n),this.timeStamp=e&&e.timeStamp||b.now(),this[b.expando]=!0,t):new b.Event(e,n)},b.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},b.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){b.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj; -return(!i||i!==r&&!b.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),b.support.submitBubbles||(b.event.special.submit={setup:function(){return b.nodeName(this,"form")?!1:(b.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=b.nodeName(n,"input")||b.nodeName(n,"button")?n.form:t;r&&!b._data(r,"submitBubbles")&&(b.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),b._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&b.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return b.nodeName(this,"form")?!1:(b.event.remove(this,"._submit"),t)}}),b.support.changeBubbles||(b.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(b.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),b.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),b.event.simulate("change",this,e,!0)})),!1):(b.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!b._data(t,"changeBubbles")&&(b.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||b.event.simulate("change",this.parentNode,e,!0)}),b._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return b.event.remove(this,"._change"),!Z.test(this.nodeName)}}),b.support.focusinBubbles||b.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){b.event.simulate(t,e.target,b.event.fix(e),!0)};b.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),b.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return b().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=b.guid++)),this.each(function(){b.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,b(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){b.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){b.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?b.event.trigger(e,n,r,!0):t}}),function(e,t){var n,r,i,o,a,s,u,l,c,p,f,d,h,g,m,y,v,x="sizzle"+-new Date,w=e.document,T={},N=0,C=0,k=it(),E=it(),S=it(),A=typeof t,j=1<<31,D=[],L=D.pop,H=D.push,q=D.slice,M=D.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},_="[\\x20\\t\\r\\n\\f]",F="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=F.replace("w","w#"),B="([*^$|!~]?=)",P="\\["+_+"*("+F+")"+_+"*(?:"+B+_+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+O+")|)|)"+_+"*\\]",R=":("+F+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+P.replace(3,8)+")*)|.*)\\)|)",W=RegExp("^"+_+"+|((?:^|[^\\\\])(?:\\\\.)*)"+_+"+$","g"),$=RegExp("^"+_+"*,"+_+"*"),I=RegExp("^"+_+"*([\\x20\\t\\r\\n\\f>+~])"+_+"*"),z=RegExp(R),X=RegExp("^"+O+"$"),U={ID:RegExp("^#("+F+")"),CLASS:RegExp("^\\.("+F+")"),NAME:RegExp("^\\[name=['\"]?("+F+")['\"]?\\]"),TAG:RegExp("^("+F.replace("w","w*")+")"),ATTR:RegExp("^"+P),PSEUDO:RegExp("^"+R),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+_+"*(even|odd|(([+-]|)(\\d*)n|)"+_+"*(?:([+-]|)"+_+"*(\\d+)|))"+_+"*\\)|)","i"),needsContext:RegExp("^"+_+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+_+"*((?:-\\d)?\\d*)"+_+"*\\)|)(?=[^-]|$)","i")},V=/[\x20\t\r\n\f]*[+~]/,Y=/^[^{]+\{\s*\[native code/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,G=/^(?:input|select|textarea|button)$/i,Q=/^h\d$/i,K=/'|\\/g,Z=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,et=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{q.call(w.documentElement.childNodes,0)[0].nodeType}catch(nt){q=function(e){var t,n=[];while(t=this[e++])n.push(t);return n}}function rt(e){return Y.test(e+"")}function it(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>i.cacheLength&&delete e[t.shift()],e[n]=r}}function ot(e){return e[x]=!0,e}function at(e){var t=p.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function st(e,t,n,r){var i,o,a,s,u,l,f,g,m,v;if((t?t.ownerDocument||t:w)!==p&&c(t),t=t||p,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!d&&!r){if(i=J.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&y(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return H.apply(n,q.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&T.getByClassName&&t.getElementsByClassName)return H.apply(n,q.call(t.getElementsByClassName(a),0)),n}if(T.qsa&&!h.test(e)){if(f=!0,g=x,m=t,v=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){l=ft(e),(f=t.getAttribute("id"))?g=f.replace(K,"\\$&"):t.setAttribute("id",g),g="[id='"+g+"'] ",u=l.length;while(u--)l[u]=g+dt(l[u]);m=V.test(e)&&t.parentNode||t,v=l.join(",")}if(v)try{return H.apply(n,q.call(m.querySelectorAll(v),0)),n}catch(b){}finally{f||t.removeAttribute("id")}}}return wt(e.replace(W,"$1"),t,n,r)}a=st.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},c=st.setDocument=function(e){var n=e?e.ownerDocument||e:w;return n!==p&&9===n.nodeType&&n.documentElement?(p=n,f=n.documentElement,d=a(n),T.tagNameNoComments=at(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),T.attributes=at(function(e){e.innerHTML="";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),T.getByClassName=at(function(e){return e.innerHTML="",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),T.getByName=at(function(e){e.id=x+0,e.innerHTML="
      ",f.insertBefore(e,f.firstChild);var t=n.getElementsByName&&n.getElementsByName(x).length===2+n.getElementsByName(x+0).length;return T.getIdNotName=!n.getElementById(x),f.removeChild(e),t}),i.attrHandle=at(function(e){return e.innerHTML="",e.firstChild&&typeof e.firstChild.getAttribute!==A&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},T.getIdNotName?(i.find.ID=function(e,t){if(typeof t.getElementById!==A&&!d){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){return e.getAttribute("id")===t}}):(i.find.ID=function(e,n){if(typeof n.getElementById!==A&&!d){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==A&&r.getAttributeNode("id").value===e?[r]:t:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){var n=typeof e.getAttributeNode!==A&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=T.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==A?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.NAME=T.getByName&&function(e,n){return typeof n.getElementsByName!==A?n.getElementsByName(name):t},i.find.CLASS=T.getByClassName&&function(e,n){return typeof n.getElementsByClassName===A||d?t:n.getElementsByClassName(e)},g=[],h=[":focus"],(T.qsa=rt(n.querySelectorAll))&&(at(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||h.push("\\["+_+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||h.push(":checked")}),at(function(e){e.innerHTML="",e.querySelectorAll("[i^='']").length&&h.push("[*^$]="+_+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")})),(T.matchesSelector=rt(m=f.matchesSelector||f.mozMatchesSelector||f.webkitMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&&at(function(e){T.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",R)}),h=RegExp(h.join("|")),g=RegExp(g.join("|")),y=rt(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},v=f.compareDocumentPosition?function(e,t){var r;return e===t?(u=!0,0):(r=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&r||e.parentNode&&11===e.parentNode.nodeType?e===n||y(w,e)?-1:t===n||y(w,t)?1:0:4&r?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return u=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:0;if(o===a)return ut(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?ut(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},u=!1,[0,0].sort(v),T.detectDuplicates=u,p):p},st.matches=function(e,t){return st(e,null,null,t)},st.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Z,"='$1']"),!(!T.matchesSelector||d||g&&g.test(t)||h.test(t)))try{var n=m.call(e,t);if(n||T.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return st(t,p,null,[e]).length>0},st.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},st.attr=function(e,t){var n;return(e.ownerDocument||e)!==p&&c(e),d||(t=t.toLowerCase()),(n=i.attrHandle[t])?n(e):d||T.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},st.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},st.uniqueSort=function(e){var t,n=[],r=1,i=0;if(u=!T.detectDuplicates,e.sort(v),u){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));while(i--)e.splice(n[i],1)}return e};function ut(e,t){var n=t&&e,r=n&&(~t.sourceIndex||j)-(~e.sourceIndex||j);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function lt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function ct(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pt(e){return ot(function(t){return t=+t,ot(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}o=st.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=st.selectors={cacheLength:50,createPseudo:ot,match:U,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(et,tt),e[3]=(e[4]||e[5]||"").replace(et,tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||st.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&st.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return U.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&z.test(n)&&(t=ft(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(et,tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=k[e+" "];return t||(t=RegExp("(^|"+_+")"+e+"("+_+"|$)"))&&k(e,function(e){return t.test(e.className||typeof e.getAttribute!==A&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=st.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[x]||(m[x]={}),l=c[e]||[],d=l[0]===N&&l[1],f=l[0]===N&&l[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[N,d,f];break}}else if(v&&(l=(t[x]||(t[x]={}))[e])&&l[0]===N)f=l[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[x]||(p[x]={}))[e]=[N,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||st.error("unsupported pseudo: "+e);return r[x]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ot(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=M.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ot(function(e){var t=[],n=[],r=s(e.replace(W,"$1"));return r[x]?ot(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ot(function(e){return function(t){return st(e,t).length>0}}),contains:ot(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ot(function(e){return X.test(e||"")||st.error("unsupported lang: "+e),e=e.replace(et,tt).toLowerCase(),function(t){var n;do if(n=d?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return Q.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:pt(function(){return[0]}),last:pt(function(e,t){return[t-1]}),eq:pt(function(e,t,n){return[0>n?n+t:n]}),even:pt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:pt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:pt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:pt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[n]=lt(n);for(n in{submit:!0,reset:!0})i.pseudos[n]=ct(n);function ft(e,t){var n,r,o,a,s,u,l,c=E[e+" "];if(c)return t?0:c.slice(0);s=e,u=[],l=i.preFilter;while(s){(!n||(r=$.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(o=[])),n=!1,(r=I.exec(s))&&(n=r.shift(),o.push({value:n,type:r[0].replace(W," ")}),s=s.slice(n.length));for(a in i.filter)!(r=U[a].exec(s))||l[a]&&!(r=l[a](r))||(n=r.shift(),o.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?st.error(e):E(e,u).slice(0)}function dt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function ht(e,t,n){var i=t.dir,o=n&&"parentNode"===i,a=C++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,s){var u,l,c,p=N+" "+a;if(s){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[x]||(t[x]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,s)||r,l[1]===!0)return!0}}function gt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function mt(e,t,n,r,i){var o,a=[],s=0,u=e.length,l=null!=t;for(;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function yt(e,t,n,r,i,o){return r&&!r[x]&&(r=yt(r)),i&&!i[x]&&(i=yt(i,o)),ot(function(o,a,s,u){var l,c,p,f=[],d=[],h=a.length,g=o||xt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:mt(g,f,e,s,u),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,u),r){l=mt(y,d),r(l,[],s,u),c=l.length;while(c--)(p=l[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?M.call(o,p):f[c])>-1&&(o[l]=!(a[l]=p))}}else y=mt(y===a?y.splice(h,y.length):y),i?i(null,a,y,u):H.apply(a,y)})}function vt(e){var t,n,r,o=e.length,a=i.relative[e[0].type],s=a||i.relative[" "],u=a?1:0,c=ht(function(e){return e===t},s,!0),p=ht(function(e){return M.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>u;u++)if(n=i.relative[e[u].type])f=[ht(gt(f),n)];else{if(n=i.filter[e[u].type].apply(null,e[u].matches),n[x]){for(r=++u;o>r;r++)if(i.relative[e[r].type])break;return yt(u>1&>(f),u>1&&dt(e.slice(0,u-1)).replace(W,"$1"),n,r>u&&vt(e.slice(u,r)),o>r&&vt(e=e.slice(r)),o>r&&dt(e))}f.push(n)}return gt(f)}function bt(e,t){var n=0,o=t.length>0,a=e.length>0,s=function(s,u,c,f,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,T=l,C=s||a&&i.find.TAG("*",d&&u.parentNode||u),k=N+=null==T?1:Math.random()||.1;for(w&&(l=u!==p&&u,r=n);null!=(h=C[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,u,c)){f.push(h);break}w&&(N=k,r=++n)}o&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,o&&b!==v){g=0;while(m=t[g++])m(x,y,u,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=L.call(f));y=mt(y)}H.apply(f,y),w&&!s&&y.length>0&&v+t.length>1&&st.uniqueSort(f)}return w&&(N=k,l=T),x};return o?ot(s):s}s=st.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=ft(e)),n=t.length;while(n--)o=vt(t[n]),o[x]?r.push(o):i.push(o);o=S(e,bt(i,r))}return o};function xt(e,t,n){var r=0,i=t.length;for(;i>r;r++)st(e,t[r],n);return n}function wt(e,t,n,r){var o,a,u,l,c,p=ft(e);if(!r&&1===p.length){if(a=p[0]=p[0].slice(0),a.length>2&&"ID"===(u=a[0]).type&&9===t.nodeType&&!d&&i.relative[a[1].type]){if(t=i.find.ID(u.matches[0].replace(et,tt),t)[0],!t)return n;e=e.slice(a.shift().value.length)}o=U.needsContext.test(e)?0:a.length;while(o--){if(u=a[o],i.relative[l=u.type])break;if((c=i.find[l])&&(r=c(u.matches[0].replace(et,tt),V.test(a[0].type)&&t.parentNode||t))){if(a.splice(o,1),e=r.length&&dt(a),!e)return H.apply(n,q.call(r,0)),n;break}}}return s(e,p)(r,t,d,n,V.test(e)),n}i.pseudos.nth=i.pseudos.eq;function Tt(){}i.filters=Tt.prototype=i.pseudos,i.setFilters=new Tt,c(),st.attr=b.attr,b.find=st,b.expr=st.selectors,b.expr[":"]=b.expr.pseudos,b.unique=st.uniqueSort,b.text=st.getText,b.isXMLDoc=st.isXML,b.contains=st.contains}(e);var at=/Until$/,st=/^(?:parents|prev(?:Until|All))/,ut=/^.[^:#\[\.,]*$/,lt=b.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};b.fn.extend({find:function(e){var t,n,r,i=this.length;if("string"!=typeof e)return r=this,this.pushStack(b(e).filter(function(){for(t=0;i>t;t++)if(b.contains(r[t],this))return!0}));for(n=[],t=0;i>t;t++)b.find(e,this[t],n);return n=this.pushStack(i>1?b.unique(n):n),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=b(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(b.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e,!1))},filter:function(e){return this.pushStack(ft(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?lt.test(e)?b(e,this.context).index(this[0])>=0:b.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){var n,r=0,i=this.length,o=[],a=lt.test(e)||"string"!=typeof e?b(e,t||this.context):0;for(;i>r;r++){n=this[r];while(n&&n.ownerDocument&&n!==t&&11!==n.nodeType){if(a?a.index(n)>-1:b.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}}return this.pushStack(o.length>1?b.unique(o):o)},index:function(e){return e?"string"==typeof e?b.inArray(this[0],b(e)):b.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?b(e,t):b.makeArray(e&&e.nodeType?[e]:e),r=b.merge(this.get(),n);return this.pushStack(b.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),b.fn.andSelf=b.fn.addBack;function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}b.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return b.dir(e,"parentNode")},parentsUntil:function(e,t,n){return b.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return b.dir(e,"nextSibling")},prevAll:function(e){return b.dir(e,"previousSibling")},nextUntil:function(e,t,n){return b.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return b.dir(e,"previousSibling",n)},siblings:function(e){return b.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return b.sibling(e.firstChild)},contents:function(e){return b.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:b.merge([],e.childNodes)}},function(e,t){b.fn[e]=function(n,r){var i=b.map(this,t,n);return at.test(e)||(r=n),r&&"string"==typeof r&&(i=b.filter(r,i)),i=this.length>1&&!ct[e]?b.unique(i):i,this.length>1&&st.test(e)&&(i=i.reverse()),this.pushStack(i)}}),b.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?b.find.matchesSelector(t[0],e)?[t[0]]:[]:b.find.matches(e,t)},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!b(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(t=t||0,b.isFunction(t))return b.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return b.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=b.grep(e,function(e){return 1===e.nodeType});if(ut.test(t))return b.filter(t,r,!n);t=b.filter(t,r)}return b.grep(e,function(e){return b.inArray(e,t)>=0===n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/
      ","
      "],tr:[2,"","
      "],col:[2,"","
      "],td:[3,"","
      "],_default:b.support.htmlSerialize?[0,"",""]:[1,"X
      ","
      "]},jt=dt(o),Dt=jt.appendChild(o.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,b.fn.extend({text:function(e){return b.access(this,function(e){return e===t?b.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(b.isFunction(e))return this.each(function(t){b(this).wrapAll(e.call(this,t))});if(this[0]){var t=b(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return b.isFunction(e)?this.each(function(t){b(this).wrapInner(e.call(this,t))}):this.each(function(){var t=b(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=b.isFunction(e);return this.each(function(n){b(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){b.nodeName(this,"body")||b(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=0;for(;null!=(n=this[r]);r++)(!e||b.filter(e,[n]).length>0)&&(t||1!==n.nodeType||b.cleanData(Ot(n)),n.parentNode&&(t&&b.contains(n.ownerDocument,n)&&Mt(Ot(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&b.cleanData(Ot(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&b.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return b.clone(this,e,t)})},html:function(e){return b.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!b.support.htmlSerialize&&mt.test(e)||!b.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(b.cleanData(Ot(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=b.isFunction(e);return t||"string"==typeof e||(e=b(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;n&&(b(this).remove(),n.insertBefore(e,t))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=f.apply([],e);var i,o,a,s,u,l,c=0,p=this.length,d=this,h=p-1,g=e[0],m=b.isFunction(g);if(m||!(1>=p||"string"!=typeof g||b.support.checkClone)&&Ct.test(g))return this.each(function(i){var o=d.eq(i);m&&(e[0]=g.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(p&&(l=b.buildFragment(e,this[0].ownerDocument,!1,this),i=l.firstChild,1===l.childNodes.length&&(l=i),i)){for(n=n&&b.nodeName(i,"tr"),s=b.map(Ot(l,"script"),Ht),a=s.length;p>c;c++)o=l,c!==h&&(o=b.clone(o,!0,!0),a&&b.merge(s,Ot(o,"script"))),r.call(n&&b.nodeName(this[c],"table")?Lt(this[c],"tbody"):this[c],o,c);if(a)for(u=s[s.length-1].ownerDocument,b.map(s,qt),c=0;a>c;c++)o=s[c],kt.test(o.type||"")&&!b._data(o,"globalEval")&&b.contains(u,o)&&(o.src?b.ajax({url:o.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):b.globalEval((o.text||o.textContent||o.innerHTML||"").replace(St,"")));l=i=null}return this}});function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function Ht(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function Mt(e,t){var n,r=0;for(;null!=(n=e[r]);r++)b._data(n,"globalEval",!t||b._data(t[r],"globalEval"))}function _t(e,t){if(1===t.nodeType&&b.hasData(e)){var n,r,i,o=b._data(e),a=b._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)b.event.add(t,n,s[n][r])}a.data&&(a.data=b.extend({},a.data))}}function Ft(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!b.support.noCloneEvent&&t[b.expando]){i=b._data(t);for(r in i.events)b.removeEvent(t,r,i.handle);t.removeAttribute(b.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),b.support.html5Clone&&e.innerHTML&&!b.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Nt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}b.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){b.fn[e]=function(e){var n,r=0,i=[],o=b(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),b(o[r])[t](n),d.apply(i,n.get());return this.pushStack(i)}});function Ot(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||b.nodeName(o,n)?s.push(o):b.merge(s,Ot(o,n));return n===t||n&&b.nodeName(e,n)?b.merge([e],s):s}function Bt(e){Nt.test(e.type)&&(e.defaultChecked=e.checked)}b.extend({clone:function(e,t,n){var r,i,o,a,s,u=b.contains(e.ownerDocument,e);if(b.support.html5Clone||b.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(b.support.noCloneEvent&&b.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||b.isXMLDoc(e)))for(r=Ot(o),s=Ot(e),a=0;null!=(i=s[a]);++a)r[a]&&Ft(i,r[a]);if(t)if(n)for(s=s||Ot(e),r=r||Ot(o),a=0;null!=(i=s[a]);a++)_t(i,r[a]);else _t(e,o);return r=Ot(o,"script"),r.length>0&&Mt(r,!u&&Ot(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,u,l,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===b.type(o))b.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),u=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[u]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!b.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!b.support.tbody){o="table"!==u||xt.test(o)?""!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)b.nodeName(l=o.childNodes[i],"tbody")&&!l.childNodes.length&&o.removeChild(l) -}b.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),b.support.appendChecked||b.grep(Ot(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===b.inArray(o,r))&&(a=b.contains(o.ownerDocument,o),s=Ot(f.appendChild(o),"script"),a&&Mt(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,u=b.expando,l=b.cache,p=b.support.deleteExpando,f=b.event.special;for(;null!=(n=e[s]);s++)if((t||b.acceptData(n))&&(o=n[u],a=o&&l[o])){if(a.events)for(r in a.events)f[r]?b.event.remove(n,r):b.removeEvent(n,r,a.handle);l[o]&&(delete l[o],p?delete n[u]:typeof n.removeAttribute!==i?n.removeAttribute(u):n[u]=null,c.push(o))}}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+x+")(.*)$","i"),Yt=RegExp("^("+x+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+x+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===b.css(e,"display")||!b.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=b._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=b._data(r,"olddisplay",un(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&b._data(r,"olddisplay",i?n:b.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}b.fn.extend({css:function(e,n){return b.access(this,function(e,n,r){var i,o,a={},s=0;if(b.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=b.css(e,n[s],!1,o);return a}return r!==t?b.style(e,n,r):b.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:nn(this))?b(this).show():b(this).hide()})}}),b.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":b.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=b.camelCase(n),l=e.style;if(n=b.cssProps[u]||(b.cssProps[u]=tn(l,u)),s=b.cssHooks[n]||b.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(b.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||b.cssNumber[u]||(r+="px"),b.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=b.camelCase(n);return n=b.cssProps[u]||(b.cssProps[u]=tn(e.style,u)),s=b.cssHooks[n]||b.cssHooks[u],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||b.isNumeric(o)?o||0:a):a},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||b.contains(e.ownerDocument,e)||(u=b.style(e,n)),Yt.test(u)&&Ut.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):o.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),Yt.test(u)&&!zt.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=b.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=b.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=b.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=b.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=b.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=b.support.boxSizing&&"border-box"===b.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(b.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function un(e){var t=o,n=Gt[e];return n||(n=ln(e,t),"none"!==n&&n||(Pt=(Pt||b("