diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 6ca383f87d0..2909e518efe 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -12,7 +12,7 @@ on: env: MONGODB_VERSION: 6.0 - NODE_VERSION: '18' + NODE_VERSION: '20' jobs: migration: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 170ef80cd1d..c6508edb8a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ permissions: env: MONGODB_VERSION: 6.0 - NODE_VERSION: '18' + NODE_VERSION: '20' jobs: feathers_tests_cov: runs-on: ubuntu-latest diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000000..b6f27f13595 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc index 3c032078a4a..209e3ef4b62 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/Dockerfile b/Dockerfile index 036bd9734d3..9871502c55a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM docker.io/node:18 AS git +FROM docker.io/node:20 AS git RUN mkdir /app && chown -R node:node /app WORKDIR /app COPY .git . RUN git config --global --add safe.directory /app && echo "{\"sha\": \"$(git rev-parse HEAD)\", \"version\": \"$(git describe --tags --abbrev=0)\", \"commitDate\": \"$(git log -1 --format=%cd --date=format:'%Y-%m-%dT%H:%M:%SZ')\", \"birthdate\": \"$(date +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/serverversion -FROM docker.io/node:18-alpine +FROM docker.io/node:20-alpine ENV TZ=Europe/Berlin RUN apk add --no-cache git make python3 # to run ldap sync as script curl is needed diff --git a/ansible/roles/common-cartridge/meta/main.yml b/ansible/roles/common-cartridge/meta/main.yml new file mode 100644 index 00000000000..a9c0f26f907 --- /dev/null +++ b/ansible/roles/common-cartridge/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + role_name: common-cartridge + author: Schul-Cloud Verbund + description: Role for installing common cartridge import export micro service + company: Schul-Cloud Verbund + license: license (AGPLv3) + min_ansible_version: "2.8" + galaxy_tags: [] +dependencies: [] diff --git a/ansible/roles/common-cartridge/tasks/main.yml b/ansible/roles/common-cartridge/tasks/main.yml new file mode 100644 index 00000000000..c1908988884 --- /dev/null +++ b/ansible/roles/common-cartridge/tasks/main.yml @@ -0,0 +1,48 @@ + +- name: Configmap + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: configmap.yml.j2 + when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - configmap + +- name: 1Password + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: onepassword.yml.j2 + when: + - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - 1password + +- name: Deployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: deployment.yml.j2 + when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - deployment + +- name: Service + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: svc.yml.j2 + when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - service + +# This is a testing route and will not be deployed +# - name: Ingress +# kubernetes.core.k8s: +# kubeconfig: ~/.kube/config +# namespace: "{{ NAMESPACE }}" +# template: ingress.yml.j2 +# when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool +# tags: +# - ingress diff --git a/ansible/roles/schulcloud-server-core/templates/common-cartridge-configmap.yml.j2 b/ansible/roles/common-cartridge/templates/configmap.yml.j2 similarity index 100% rename from ansible/roles/schulcloud-server-core/templates/common-cartridge-configmap.yml.j2 rename to ansible/roles/common-cartridge/templates/configmap.yml.j2 diff --git a/ansible/roles/schulcloud-server-core/templates/common-cartridge-deployment.yml.j2 b/ansible/roles/common-cartridge/templates/deployment.yml.j2 similarity index 100% rename from ansible/roles/schulcloud-server-core/templates/common-cartridge-deployment.yml.j2 rename to ansible/roles/common-cartridge/templates/deployment.yml.j2 diff --git a/ansible/roles/common-cartridge/templates/ingress.yml.j2 b/ansible/roles/common-cartridge/templates/ingress.yml.j2 new file mode 100644 index 00000000000..209242e697f --- /dev/null +++ b/ansible/roles/common-cartridge/templates/ingress.yml.j2 @@ -0,0 +1,34 @@ +#jinja2: trim_blocks: "True", lstrip_blocks: "True" +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ NAMESPACE }}-common-cartridge-ingress + namespace: {{ NAMESPACE }} + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABLED|default("false") }}" + nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + # The following properties added with BC-3606. + # The header size of the request is too big. For e.g. state and the permanent growing jwt. + # Nginx throws away the Location header, resulting in the 502 Bad Gateway. + nginx.ingress.kubernetes.io/client-header-buffer-size: 100k + nginx.ingress.kubernetes.io/http2-max-header-size: 96k + nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k + nginx.ingress.kubernetes.io/proxy-buffer-size: 96k +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} +spec: + ingressClassName: {{ INGRESS_CLASS }} + rules: + {# This is a testing route and will be removed in the future #} + - host: {{ DOMAIN }} + http: + paths: + - path: /api/v3/common-cartridge/ + pathType: Prefix + backend: + service: + name: common-cartridge-svc + port: + number: 3350 diff --git a/ansible/roles/schulcloud-server-core/templates/common-cartridge-onepassword.yml.j2 b/ansible/roles/common-cartridge/templates/onepassword.yml.j2 similarity index 100% rename from ansible/roles/schulcloud-server-core/templates/common-cartridge-onepassword.yml.j2 rename to ansible/roles/common-cartridge/templates/onepassword.yml.j2 diff --git a/ansible/roles/common-cartridge/templates/svc.yml.j2 b/ansible/roles/common-cartridge/templates/svc.yml.j2 new file mode 100644 index 00000000000..c6c7755b369 --- /dev/null +++ b/ansible/roles/common-cartridge/templates/svc.yml.j2 @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: common-cartridge-svc + namespace: {{ NAMESPACE }} + labels: + app: common-cartridge +spec: + type: ClusterIP + selector: + app: common-cartridge + ports: + - name: common-cartridge + protocol: TCP + port: 3350 + targetPort: 3350 \ No newline at end of file diff --git a/ansible/roles/h5p-library-management/tasks/main.yml b/ansible/roles/h5p-library-management/tasks/main.yml index 695c1448ee9..d3fa4c370df 100644 --- a/ansible/roles/h5p-library-management/tasks/main.yml +++ b/ansible/roles/h5p-library-management/tasks/main.yml @@ -4,6 +4,8 @@ namespace: "{{ NAMESPACE }}" template: api-h5p-library-management-onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool and WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true + tags: + - 1password - name: H5pLibraryManagement ConfigMap when: WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true @@ -11,6 +13,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-h5p-library-management-configmap.yml.j2 + tags: + - configmap - name: H5pLibraryManagement CronJob when: WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true @@ -18,3 +22,5 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-h5p-library-management-cronjob.yml.j2 + tags: + - cronjob diff --git a/ansible/roles/moin-schule-sync/tasks/main.yml b/ansible/roles/moin-schule-sync/tasks/main.yml index ec9bd313d8a..edec98100d7 100644 --- a/ansible/roles/moin-schule-sync/tasks/main.yml +++ b/ansible/roles/moin-schule-sync/tasks/main.yml @@ -4,6 +4,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: moin-schule-sync-onepassword.yml.j2 + tags: + - 1password - name: moin.schule users sync CronJob when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true @@ -11,6 +13,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: moin-schule-users-sync-cronjob.yml.j2 + tags: + - cronjob - name: moin.schule users sync CronJob ConfigMap when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true @@ -18,6 +22,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: moin-schule-users-sync-cronjob-configmap.yml.j2 + tags: + - configmap - name: unsynced moin.schule users deletion queueing CronJob when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true and WITH_UNSYNCED_ENTITIES_DELETION is defined and WITH_UNSYNCED_ENTITIES_DELETION|bool == true @@ -25,6 +31,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: moin-schule-users-deletion-queueing-cronjob.yml.j2 + tags: + - cronjob - name: unsynced moin.schule users deletion queueing CronJob ConfigMap when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true and WITH_UNSYNCED_ENTITIES_DELETION is defined and WITH_UNSYNCED_ENTITIES_DELETION|bool == true @@ -32,3 +40,5 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: moin-schule-users-deletion-queueing-cronjob-configmap.yml.j2 + tags: + - configmap diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index dcc0a88cd79..25b0880786c 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -3,18 +3,24 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: svc.yml.j2 + tags: + - service - name: ServiceMonitor kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: svc-monitor.yml.j2 + tags: + - prometheus - name: FileStorageService kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-files-svc.yml.j2 + tags: + - service - name: FwuLearningContentsService kubernetes.core.k8s: @@ -22,6 +28,8 @@ namespace: "{{ NAMESPACE }}" template: api-fwu-svc.yml.j2 when: FEATURE_FWU_CONTENT_ENABLED is defined and FEATURE_FWU_CONTENT_ENABLED|bool + tags: + - service - name: Configmap kubernetes.core.k8s: @@ -29,6 +37,8 @@ namespace: "{{ NAMESPACE }}" template: configmap.yml.j2 apply: yes + tags: + - configmap - name: Secret by 1Password kubernetes.core.k8s: @@ -36,6 +46,8 @@ namespace: "{{ NAMESPACE }}" template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + tags: + - 1password - name: File Storage Secret by 1Password kubernetes.core.k8s: @@ -43,6 +55,8 @@ namespace: "{{ NAMESPACE }}" template: api-files-onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + tags: + - 1password - name: Admin Api ingress kubernetes.core.k8s: @@ -51,6 +65,8 @@ template: admin-api-ingress.yml.j2 apply: yes when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - ingress - name: Admin API server ConfigMap kubernetes.core.k8s: @@ -59,6 +75,8 @@ template: admin-api-server-configmap.yml.j2 apply: yes when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - configmap - name: Admin API server Secret (from 1Password) kubernetes.core.k8s: @@ -68,6 +86,8 @@ when: - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool - WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - 1password - name: Admin API client secret (from 1Password) kubernetes.core.k8s: @@ -77,6 +97,8 @@ when: - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool - WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - 1password - name: remove old migration Job kubernetes.core.k8s: @@ -87,18 +109,24 @@ name: api-migration-job state: absent wait: yes + tags: + - job - name: migration Job kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: migration-job.yml.j2 + tags: + - job - name: Deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: deployment.yml.j2 + tags: + - deployment - name: Ingress kubernetes.core.k8s: @@ -106,12 +134,16 @@ namespace: "{{ NAMESPACE }}" template: ingress.yml.j2 apply: yes + tags: + - ingress - name: FileStorageDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-files-deployment.yml.j2 + tags: + - deployment - name: File Storage Ingress kubernetes.core.k8s: @@ -119,6 +151,8 @@ namespace: "{{ NAMESPACE }}" template: api-files-ingress.yml.j2 apply: yes + tags: + - ingress - name: FwuLearningContentsDeployment kubernetes.core.k8s: @@ -126,6 +160,8 @@ namespace: "{{ NAMESPACE }}" template: api-fwu-deployment.yml.j2 when: FEATURE_FWU_CONTENT_ENABLED is defined and FEATURE_FWU_CONTENT_ENABLED|bool + tags: + - deployment - name: Fwu Learning Contents Ingress Remove kubernetes.core.k8s: @@ -136,12 +172,16 @@ kind: Ingress name: "{{ NAMESPACE }}-api-fwu-ingress" when: FEATURE_FWU_CONTENT_ENABLED is defined and FEATURE_FWU_CONTENT_ENABLED|bool + tags: + - ingress - name: Delete Files CronJob kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + tags: + - cronjob - name: Delete Tldraw Files CronJob kubernetes.core.k8s: @@ -149,6 +189,8 @@ namespace: "{{ NAMESPACE }}" template: tldraw-delete-files-cronjob.yml.j2 when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + tags: + - cronjob - name: Data deletion trigger CronJob kubernetes.core.k8s: @@ -156,6 +198,8 @@ namespace: "{{ NAMESPACE }}" template: data-deletion-trigger-cronjob.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - cronjob - name: Data deletion trigger CronJob ConfigMap kubernetes.core.k8s: @@ -163,18 +207,24 @@ namespace: "{{ NAMESPACE }}" template: data-deletion-trigger-cronjob-configmap.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - configmap - name: amqp files storage Deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: amqp-files-deployment.yml.j2 + tags: + - deployment - name: amqp files storage configmap kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: amqp-files-configmap.yml.j2 + tags: + - configmap - name: amqp files storage Secret by 1Password kubernetes.core.k8s: @@ -182,12 +232,16 @@ namespace: "{{ NAMESPACE }}" template: amqp-files-onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + tags: + - 1password - name: Preview Generator Deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: preview-generator-deployment.yml.j2 + tags: + - deployment - name: preview generator configmap kubernetes.core.k8s: @@ -195,6 +249,8 @@ namespace: "{{ NAMESPACE }}" template: preview-generator-configmap.yml.j2 apply: yes + tags: + - configmap - name: preview generator Secret by 1Password kubernetes.core.k8s: @@ -202,6 +258,8 @@ namespace: "{{ NAMESPACE }}" template: preview-generator-onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + tags: + - 1password - name: preview generator scaled object kubernetes.core.k8s: @@ -211,6 +269,8 @@ when: - KEDA_ENABLED is defined and KEDA_ENABLED|bool - SCALED_PREVIEW_GENERATOR_ENABLED is defined and SCALED_PREVIEW_GENERATOR_ENABLED|bool + tags: + - keda - name: admin api server deployment kubernetes.core.k8s: @@ -218,6 +278,8 @@ namespace: "{{ NAMESPACE }}" template: admin-api-server-deployment.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - deployment - name: admin api server service kubernetes.core.k8s: @@ -225,6 +287,8 @@ namespace: "{{ NAMESPACE }}" template: admin-api-server-svc.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - service - name: admin api server service monitor kubernetes.core.k8s: @@ -232,6 +296,8 @@ namespace: "{{ NAMESPACE }}" template: admin-api-server-svc-monitor.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + tags: + - prometheus - name: TlDraw server Secret (from 1Password) kubernetes.core.k8s: @@ -241,6 +307,8 @@ when: - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool - WITH_TLDRAW is defined and WITH_TLDRAW|bool + tags: + - 1password - name: TlDraw server deployment kubernetes.core.k8s: @@ -248,6 +316,8 @@ namespace: "{{ NAMESPACE }}" template: tldraw-deployment.yml.j2 when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + tags: + - deployment - name: TlDraw server service kubernetes.core.k8s: @@ -255,6 +325,8 @@ namespace: "{{ NAMESPACE }}" template: tldraw-server-svc.yml.j2 when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + tags: + - service - name: Tldraw ingress kubernetes.core.k8s: @@ -263,6 +335,8 @@ template: tldraw-ingress.yml.j2 apply: yes when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + tags: + - ingress - name: TldrawServiceMonitor kubernetes.core.k8s: @@ -270,30 +344,8 @@ namespace: "{{ NAMESPACE }}" template: tldraw-svc-monitor.yml.j2 when: WITH_TLDRAW is defined and WITH_TLDRAW|bool - - - name: common cartridge configmap - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: common-cartridge-configmap.yml.j2 - apply: yes - when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool - - - name: common cartridge Secret by 1Password - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: common-cartridge-onepassword.yml.j2 - when: - - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool - - WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool - - - name: common cartridge deployment - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: common-cartridge-deployment.yml.j2 - when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - prometheus - name: BoardCollaboration configmap kubernetes.core.k8s: @@ -301,6 +353,8 @@ namespace: "{{ NAMESPACE }}" template: board-collaboration-configmap.yml.j2 state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + tags: + - configmap - name: BoardCollaboration deployment kubernetes.core.k8s: @@ -308,6 +362,8 @@ namespace: "{{ NAMESPACE }}" template: board-collaboration-deployment.yml.j2 state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + tags: + - deployment - name: BoardCollaboration service kubernetes.core.k8s: @@ -315,6 +371,8 @@ namespace: "{{ NAMESPACE }}" template: board-collaboration-service.yml.j2 state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + tags: + - service - name: BoardCollaboration ingress kubernetes.core.k8s: @@ -323,6 +381,8 @@ template: board-collaboration-ingress.yml.j2 apply: yes state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + tags: + - ingress - name: BoardCollaborationServiceMonitor kubernetes.core.k8s: @@ -330,3 +390,5 @@ namespace: '{{ NAMESPACE }}' template: board-collaboration-svc-monitor.yml.j2 state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + tags: + - prometheus diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 index f6f8783dc13..56cf6d540c8 100644 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 @@ -64,6 +64,9 @@ spec: name: tldraw-server-secret - secretRef: name: api-files-secret + env: + - name: NODE_OPTIONS + value: "--max-old-space-size=4096" command: ['npm', 'run', 'nest:start:tldraw:prod'] resources: limits: diff --git a/ansible/roles/schulcloud-server-h5p/tasks/main.yml b/ansible/roles/schulcloud-server-h5p/tasks/main.yml index 1063f5e098d..370d66cff07 100644 --- a/ansible/roles/schulcloud-server-h5p/tasks/main.yml +++ b/ansible/roles/schulcloud-server-h5p/tasks/main.yml @@ -4,6 +4,8 @@ namespace: "{{ NAMESPACE }}" template: api-h5p-onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool and WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + tags: + - 1password - name: H5PEditorProvider kubernetes.core.k8s: @@ -11,6 +13,8 @@ namespace: "{{ NAMESPACE }}" template: api-h5p-svc.yml.j2 when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + tags: + - service - name: H5pEditorDeployment kubernetes.core.k8s: @@ -18,3 +22,5 @@ namespace: "{{ NAMESPACE }}" template: api-h5p-deployment.yml.j2 when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + tags: + - deployment diff --git a/ansible/roles/schulcloud-server-init/tasks/main.yml b/ansible/roles/schulcloud-server-init/tasks/main.yml index 6993bb3448a..9051679e0ce 100644 --- a/ansible/roles/schulcloud-server-init/tasks/main.yml +++ b/ansible/roles/schulcloud-server-init/tasks/main.yml @@ -5,6 +5,8 @@ namespace: "{{ NAMESPACE }}" template: configmap_file_init.yml.j2 when: WITH_SCHULCLOUD_INIT + tags: + - configmap - name: Remove Init Configmap File kubernetes.core.k8s: @@ -15,6 +17,8 @@ kind: ConfigMap name: api-init-file when: not WITH_SCHULCLOUD_INIT + tags: + - configmap - name: Management Deployment kubernetes.core.k8s: @@ -22,6 +26,8 @@ namespace: "{{ NAMESPACE }}" template: management-deployment.yml.j2 when: WITH_SCHULCLOUD_INIT + tags: + - deployment - name: Remove management Deployment kubernetes.core.k8s: @@ -32,6 +38,8 @@ kind: Deployment name: management-deployment when: not WITH_SCHULCLOUD_INIT + tags: + - deployment - name: Management Service kubernetes.core.k8s: @@ -39,6 +47,8 @@ namespace: "{{ NAMESPACE }}" template: management-svc.yml.j2 when: WITH_SCHULCLOUD_INIT + tags: + - service - name: Remove management Service kubernetes.core.k8s: @@ -49,6 +59,8 @@ kind: Service name: mgmt-svc when: not WITH_SCHULCLOUD_INIT + tags: + - service - name: Check Init Job kubernetes.core.k8s_info: @@ -59,6 +71,8 @@ name: api-init-job register: init_job_exists ignore_errors: yes + tags: + - job - name: Init Job kubernetes.core.k8s: @@ -66,6 +80,8 @@ namespace: "{{ NAMESPACE }}" template: job_init.yml.j2 when: WITH_SCHULCLOUD_INIT and init_job_exists.resources|length == 0 + tags: + - job - name: Remove Init Job kubernetes.core.k8s: @@ -75,4 +91,6 @@ api_version: batch/v1 kind: Job name: api-init-job - when: not WITH_SCHULCLOUD_INIT \ No newline at end of file + when: not WITH_SCHULCLOUD_INIT + tags: + - job \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml b/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml index b23c1ad9b74..0a80b7e06f8 100644 --- a/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml +++ b/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml @@ -5,6 +5,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-ldap-sync-full-cronjob.yml.j2 + tags: + - cronjob - name: Ldap Worker Deployment when: WITH_LDAP is defined and WITH_LDAP|bool == true @@ -12,6 +14,8 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-ldap-worker-deployment.yml.j2 + tags: + - deployment - name: api worker scaled object kubernetes.core.k8s: @@ -22,4 +26,6 @@ - WITH_LDAP is defined and WITH_LDAP|bool - KEDA_ENABLED is defined and KEDA_ENABLED|bool - SCALED_API_WORKER_ENABLED is defined and SCALED_API_WORKER_ENABLED|bool + tags: + - keda diff --git a/ansible/roles/schulcloud-server-migration-system/tasks/main.yml b/ansible/roles/schulcloud-server-migration-system/tasks/main.yml index 21255d57e29..51d009417a1 100644 --- a/ansible/roles/schulcloud-server-migration-system/tasks/main.yml +++ b/ansible/roles/schulcloud-server-migration-system/tasks/main.yml @@ -4,6 +4,8 @@ namespace: "{{ NAMESPACE }}" template: svc.yml.j2 state: "{{ 'present' if WITH_MIGRATION_SYSTEMS else 'absent'}}" + tags: + - service - name: Deployment kubernetes.core.k8s: @@ -11,6 +13,8 @@ namespace: "{{ NAMESPACE }}" template: deployment.yml.j2 state: "{{ 'present' if WITH_MIGRATION_SYSTEMS else 'absent'}}" + tags: + - deployment - name: Ingress kubernetes.core.k8s: @@ -19,4 +23,6 @@ template: ingress.yml.j2 apply: yes state: "{{ 'present' if WITH_MIGRATION_SYSTEMS else 'absent'}}" + tags: + - ingress diff --git a/ansible/roles/schulcloud-server-tspsync/tasks/main.yml b/ansible/roles/schulcloud-server-tspsync/tasks/main.yml index b87cf00e0f0..3f323efb681 100644 --- a/ansible/roles/schulcloud-server-tspsync/tasks/main.yml +++ b/ansible/roles/schulcloud-server-tspsync/tasks/main.yml @@ -4,6 +4,8 @@ namespace: "{{ NAMESPACE }}" template: api-tsp-sync-svc.yml.j2 when: WITH_TSP + tags: + - service - name: remove API TSP Sync Service kubernetes.core.k8s: @@ -14,6 +16,8 @@ kind: Service name: api-tsp-sync-svc when: not WITH_TSP + tags: + - service - name: API TSP Sync Deployment kubernetes.core.k8s: @@ -21,6 +25,8 @@ namespace: "{{ NAMESPACE }}" template: api-tsp-sync-deployment.yml.j2 when: WITH_TSP + tags: + - deployment - name: remove API TSP Sync Deployment kubernetes.core.k8s: @@ -31,6 +37,8 @@ namespace: "{{ NAMESPACE }}" name: api-tsp-sync-deployment when: not WITH_TSP + tags: + - deployment - name: API TSP Sync Base CronJob kubernetes.core.k8s: @@ -38,6 +46,8 @@ namespace: "{{ NAMESPACE }}" template: api-tsp-sync-base-cronjob.yml.j2 when: WITH_TSP + tags: + - cronjob - name: remove API TSP Sync Base CronJob kubernetes.core.k8s: @@ -48,6 +58,8 @@ namespace: "{{ NAMESPACE }}" name: api-tsp-sync-base-cronjob when: not WITH_TSP + tags: + - cronjob - name: API TSP Sync School CronJob kubernetes.core.k8s: @@ -55,6 +67,8 @@ namespace: "{{ NAMESPACE }}" template: api-tsp-sync-school-cronjob.yml.j2 when: WITH_TSP + tags: + - cronjob - name: remove API TSP Sync School CronJob kubernetes.core.k8s: @@ -65,3 +79,5 @@ namespace: "{{ NAMESPACE }}" name: api-tsp-sync-school-cronjob when: not WITH_TSP + tags: + - cronjob diff --git a/apps/server/src/apps/admin-api-server.app.ts b/apps/server/src/apps/admin-api-server.app.ts index 818721123ee..eb8d6cad07f 100644 --- a/apps/server/src/apps/admin-api-server.app.ts +++ b/apps/server/src/apps/admin-api-server.app.ts @@ -1,13 +1,13 @@ /* istanbul ignore file */ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { NestFactory } from '@nestjs/core'; -import { install as sourceMapInstall } from 'source-map-support'; -import { LegacyLogger, Logger } from '@src/core/logger'; +import { ExpressAdapter } from '@nestjs/platform-express'; import { enableOpenApiDocs } from '@shared/controller/swagger'; import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import express from 'express'; +import { LegacyLogger, Logger } from '@src/core/logger'; import { AdminApiServerModule } from '@src/modules/server/admin-api.server.module'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import express from 'express'; +import { install as sourceMapInstall } from 'source-map-support'; import { addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, diff --git a/apps/server/src/apps/board-collaboration.app.ts b/apps/server/src/apps/board-collaboration.app.ts index 72a44d1e061..a6421f15689 100644 --- a/apps/server/src/apps/board-collaboration.app.ts +++ b/apps/server/src/apps/board-collaboration.app.ts @@ -7,7 +7,6 @@ import { install as sourceMapInstall } from 'source-map-support'; // application imports import { SwaggerDocumentOptions } from '@nestjs/swagger'; -import { DB_URL } from '@src/config'; import { LegacyLogger, Logger } from '@src/core/logger'; import { MongoIoAdapter } from '@src/infra/socketio'; import { BoardCollaborationModule } from '@src/modules/board/board-collaboration.module'; @@ -18,6 +17,7 @@ import { createAndStartPrometheusMetricsAppIfEnabled, } from '@src/apps/helpers/prometheus-metrics'; import { ExpressAdapter } from '@nestjs/platform-express'; +import { DB_URL } from '@src/config'; async function bootstrap() { sourceMapInstall(); @@ -25,12 +25,14 @@ async function bootstrap() { const nestExpress = express(); const nestExpressAdapter = new ExpressAdapter(nestExpress); const nestApp = await NestFactory.create(BoardCollaborationModule, nestExpressAdapter); - nestApp.useLogger(await nestApp.resolve(LegacyLogger)); + const legacyLogger = await nestApp.resolve(LegacyLogger); + nestApp.useLogger(legacyLogger); nestApp.enableCors({ exposedHeaders: ['Content-Disposition'] }); - const mongoIoAdapter = new MongoIoAdapter(nestApp); - await mongoIoAdapter.connectToMongoDb(DB_URL); - nestApp.useWebSocketAdapter(mongoIoAdapter); + const mongoAdapter = new MongoIoAdapter(nestApp); + await mongoAdapter.connectToMongoDb(DB_URL); + const ioAdapter = mongoAdapter; + nestApp.useWebSocketAdapter(ioAdapter); const options: SwaggerDocumentOptions = { operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey, diff --git a/apps/server/src/apps/common-cartridge.app.ts b/apps/server/src/apps/common-cartridge.app.ts index 86c80e9e7ac..21398209f8e 100644 --- a/apps/server/src/apps/common-cartridge.app.ts +++ b/apps/server/src/apps/common-cartridge.app.ts @@ -1,16 +1,17 @@ /* istanbul ignore file */ /* eslint-disable no-console */ import { NestFactory } from '@nestjs/core'; -import { LegacyLogger, Logger } from '@src/core/logger'; -import { install as sourceMapInstall } from 'source-map-support'; import { ExpressAdapter } from '@nestjs/platform-express'; -import { CommonCartridgeApiModule } from '@src/modules/common-cartridge/common-cartridge-api.module'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; import { addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, } from '@src/apps/helpers/prometheus-metrics'; -import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; +import { LegacyLogger, Logger } from '@src/core/logger'; +import { CommonCartridgeApiModule } from '@src/modules/common-cartridge/common-cartridge-api.module'; import express from 'express'; +import { install as sourceMapInstall } from 'source-map-support'; async function bootstrap() { sourceMapInstall(); @@ -18,6 +19,7 @@ async function bootstrap() { const nestExpress = express(); const nestExpressAdapter = new ExpressAdapter(nestExpress); const nestApp = await NestFactory.create(CommonCartridgeApiModule, nestExpressAdapter); + enableOpenApiDocs(nestApp, 'docs'); // WinstonLogger nestApp.useLogger(await nestApp.resolve(LegacyLogger)); diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts index f2b480a4bf7..201bf90c600 100644 --- a/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts +++ b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts @@ -17,13 +17,13 @@ describe(AxiosErrorLoggable.name, () => { }; it('should return error log message', () => { - const { axiosErrorLoggable, error, axiosError } = setup(); + const { axiosErrorLoggable, error } = setup(); const result = axiosErrorLoggable.getLogMessage(); expect(result).toEqual({ type: 'mockType', - message: axiosError.message, + message: 'message: Bad Request code: 400', data: JSON.stringify(error), stack: 'mockStack', }); diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.ts b/apps/server/src/core/error/loggable/axios-error.loggable.ts index 29e6ad32dad..c7e2d2c0fd1 100644 --- a/apps/server/src/core/error/loggable/axios-error.loggable.ts +++ b/apps/server/src/core/error/loggable/axios-error.loggable.ts @@ -11,7 +11,7 @@ export class AxiosErrorLoggable extends HttpException implements Loggable { getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { - message: this.axiosError.message, + message: `message: ${this.axiosError.message} code: ${this.axiosError.code || 'Unknown code'}`, type: this.type, data: JSON.stringify(this.axiosError.response?.data), stack: this.axiosError.stack, diff --git a/apps/server/src/imports-from-feathers.ts b/apps/server/src/imports-from-feathers.ts index 0804901a53b..4d92940f88e 100644 --- a/apps/server/src/imports-from-feathers.ts +++ b/apps/server/src/imports-from-feathers.ts @@ -1,5 +1,6 @@ /* eslint-disable import/extensions */ export { BruteForcePrevention } from '../../../src/errors/index.js'; +export { authConfig } from '../../../src/services/authentication/configuration'; export { addTokenToWhitelist, createRedisIdentifierFromJwtData, diff --git a/apps/server/src/infra/auth-guard/adapter/index.ts b/apps/server/src/infra/auth-guard/adapter/index.ts new file mode 100644 index 00000000000..96e9bc608a9 --- /dev/null +++ b/apps/server/src/infra/auth-guard/adapter/index.ts @@ -0,0 +1 @@ +export { JwtValidationAdapter } from './jwt-validation.adapter'; diff --git a/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.spec.ts b/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.spec.ts new file mode 100644 index 00000000000..e0071f43eca --- /dev/null +++ b/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.spec.ts @@ -0,0 +1,2 @@ +// isWhitelisted method is currently tested in authentication modul JwtWhitelistAdapter. Test should be moved here after feathers migration and adding of redis adapter in nest +it('just to have one test', () => {}); diff --git a/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.ts b/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.ts new file mode 100644 index 00000000000..d31ddfa0eb7 --- /dev/null +++ b/apps/server/src/infra/auth-guard/adapter/jwt-validation.adapter.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { ensureTokenIsWhitelisted } from '@src/imports-from-feathers'; + +@Injectable() +export class JwtValidationAdapter { + /** + * When validating a jwt it must be added to a whitelist, here we check this. + * When the jwt is validated, the expiration time will be extended with this call. + * @param accountId users account id + * @param jti jwt id (here required to make jwt identifiers identical in redis) + */ + async isWhitelisted(accountId: string, jti: string): Promise { + await ensureTokenIsWhitelisted({ accountId, jti, privateDevice: false }); + } +} diff --git a/apps/server/src/infra/auth-guard/auth-guard.module.ts b/apps/server/src/infra/auth-guard/auth-guard.module.ts new file mode 100644 index 00000000000..4ba91b4658b --- /dev/null +++ b/apps/server/src/infra/auth-guard/auth-guard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtValidationAdapter } from './adapter'; +import { JwtStrategy, WsJwtStrategy, XApiKeyStrategy } from './strategy'; + +@Module({ + imports: [PassportModule], + providers: [JwtStrategy, WsJwtStrategy, JwtValidationAdapter, XApiKeyStrategy], + exports: [JwtValidationAdapter], +}) +export class AuthGuardModule {} diff --git a/apps/server/src/infra/auth-guard/config/auth-config.ts b/apps/server/src/infra/auth-guard/config/auth-config.ts new file mode 100644 index 00000000000..1b3212f9414 --- /dev/null +++ b/apps/server/src/infra/auth-guard/config/auth-config.ts @@ -0,0 +1,4 @@ +import { authConfig as feathersAuthConfig } from '@src/imports-from-feathers'; +import { AuthConfigMapper } from '../mapper'; + +export const authConfig = AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(feathersAuthConfig); diff --git a/apps/server/src/modules/authentication/config/index.ts b/apps/server/src/infra/auth-guard/config/index.ts similarity index 53% rename from apps/server/src/modules/authentication/config/index.ts rename to apps/server/src/infra/auth-guard/config/index.ts index cd9c0d28b96..80219784f77 100644 --- a/apps/server/src/modules/authentication/config/index.ts +++ b/apps/server/src/infra/auth-guard/config/index.ts @@ -1 +1,2 @@ +export * from './auth-config'; export * from './x-api-key.config'; diff --git a/apps/server/src/modules/authentication/config/x-api-key.config.ts b/apps/server/src/infra/auth-guard/config/x-api-key.config.ts similarity index 100% rename from apps/server/src/modules/authentication/config/x-api-key.config.ts rename to apps/server/src/infra/auth-guard/config/x-api-key.config.ts diff --git a/apps/server/src/infra/auth-guard/decorator/index.ts b/apps/server/src/infra/auth-guard/decorator/index.ts new file mode 100644 index 00000000000..73ec25ffece --- /dev/null +++ b/apps/server/src/infra/auth-guard/decorator/index.ts @@ -0,0 +1 @@ +export * from './jwt-auth.decorator'; diff --git a/apps/server/src/modules/authentication/decorator/auth.decorator.spec.ts b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts similarity index 76% rename from apps/server/src/modules/authentication/decorator/auth.decorator.spec.ts rename to apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts index cc6006228b8..51098ec2964 100644 --- a/apps/server/src/modules/authentication/decorator/auth.decorator.spec.ts +++ b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { ICurrentUser } from '@modules/authentication'; import { ServerTestModule } from '@modules/server/server.module'; -import { Controller, ExecutionContext, ForbiddenException, Get, INestApplication } from '@nestjs/common'; +import { Controller, ExecutionContext, Get, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; -import { JwtAuthGuard } from '../guard/jwt-auth.guard'; -import { Authenticate, CurrentUser, JWT } from './auth.decorator'; +import { JwtAuthGuard } from '../guard'; +import { ICurrentUser } from '../interface'; +import { CurrentUser, JWT, JwtAuthentication } from './jwt-auth.decorator'; -@Authenticate('jwt') +@JwtAuthentication() @Controller('test_decorator_currentUser') export class TestDecoratorCurrentUserController { @Get('test') @@ -16,7 +16,7 @@ export class TestDecoratorCurrentUserController { } } -@Authenticate('jwt') +@JwtAuthentication() @Controller('test_decorator_JWT') export class TestDecoratorJWTController { @Get('test') @@ -25,7 +25,7 @@ export class TestDecoratorJWTController { } } -describe('auth.decorator', () => { +describe('Jwt auth decorator', () => { let app: INestApplication; let currentUser: ICurrentUser; let module: TestingModule; @@ -59,7 +59,7 @@ describe('auth.decorator', () => { await module.close(); }); - describe('JWT', () => { + describe('JwtAuthentication', () => { it('should throw with UnauthorizedException if no jwt can be extracted from request context', async () => { const response = await request(app.getHttpServer()).get('/test_decorator_JWT/test'); @@ -86,12 +86,4 @@ describe('auth.decorator', () => { expect(response.statusCode).toEqual(401); }); }); - - describe('Authenticate', () => { - it('should throw with UnauthorizedException if no jwt user data can be extracted from request context', () => { - // @ts-expect-error Testcase - const exec = () => Authenticate('bla'); - expect(exec).toThrowError(new ForbiddenException('jwt strategy required')); - }); - }); }); diff --git a/apps/server/src/modules/authentication/decorator/auth.decorator.ts b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.ts similarity index 68% rename from apps/server/src/modules/authentication/decorator/auth.decorator.ts rename to apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.ts index 583799977f2..d29424fc18d 100644 --- a/apps/server/src/modules/authentication/decorator/auth.decorator.ts +++ b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.ts @@ -2,35 +2,27 @@ import { applyDecorators, createParamDecorator, ExecutionContext, - ForbiddenException, UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth } from '@nestjs/swagger'; -import { Request } from 'express'; import { extractJwtFromHeader } from '@shared/common'; -import { JwtAuthGuard } from '../guard/jwt-auth.guard'; -import { ICurrentUser, isICurrentUser } from '../interface/user'; - -const STRATEGIES = ['jwt'] as const; -type Strategies = typeof STRATEGIES; +import { Request } from 'express'; +import { JwtAuthGuard } from '../guard'; +import { ICurrentUser, isICurrentUser } from '../interface'; /** * Authentication Decorator taking care of require authentication header to be present, setting up the user context and extending openAPI spec. - * @param strategies accepted strategies - * @returns */ -export const Authenticate = (...strategies: Strategies) => { - if (strategies.includes('jwt')) { - const decorators = [ - // apply jwt authentication - UseGuards(JwtAuthGuard), - // add jwt authentication to openapi spec - ApiBearerAuth(), - ]; - return applyDecorators(...decorators); - } - throw new ForbiddenException('jwt strategy required'); +export const JwtAuthentication = () => { + const decorators = [ + // apply jwt authentication + UseGuards(JwtAuthGuard), + // add jwt authentication to openapi spec + ApiBearerAuth(), + ]; + + return applyDecorators(...decorators); }; /** diff --git a/apps/server/src/infra/auth-guard/guard/index.ts b/apps/server/src/infra/auth-guard/guard/index.ts new file mode 100644 index 00000000000..0cf3a23eb41 --- /dev/null +++ b/apps/server/src/infra/auth-guard/guard/index.ts @@ -0,0 +1,3 @@ +export * from './jwt-auth.guard'; +export * from './ws-jwt-auth.guard'; +export * from './x-api-key-auth.guard'; diff --git a/apps/server/src/infra/auth-guard/guard/jwt-auth.guard.ts b/apps/server/src/infra/auth-guard/guard/jwt-auth.guard.ts new file mode 100644 index 00000000000..6f540cbdf49 --- /dev/null +++ b/apps/server/src/infra/auth-guard/guard/jwt-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { StrategyType } from '../interface'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard(StrategyType.JWT) {} diff --git a/apps/server/src/modules/authentication/guard/ws-jwt-auth.guard.ts b/apps/server/src/infra/auth-guard/guard/ws-jwt-auth.guard.ts similarity index 72% rename from apps/server/src/modules/authentication/guard/ws-jwt-auth.guard.ts rename to apps/server/src/infra/auth-guard/guard/ws-jwt-auth.guard.ts index 98f106a20a4..bf36bb45378 100644 --- a/apps/server/src/modules/authentication/guard/ws-jwt-auth.guard.ts +++ b/apps/server/src/infra/auth-guard/guard/ws-jwt-auth.guard.ts @@ -1,7 +1,8 @@ import { ExecutionContext } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { StrategyType } from '../interface'; -export class WsJwtAuthGuard extends AuthGuard('wsjwt') { +export class WsJwtAuthGuard extends AuthGuard(StrategyType.WS_JWT) { getRequest(context: ExecutionContext) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access return context.switchToWs().getClient().handshake; diff --git a/apps/server/src/infra/auth-guard/guard/x-api-key-auth.guard.ts b/apps/server/src/infra/auth-guard/guard/x-api-key-auth.guard.ts new file mode 100644 index 00000000000..beafd01d1b1 --- /dev/null +++ b/apps/server/src/infra/auth-guard/guard/x-api-key-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { StrategyType } from '../interface'; + +@Injectable() +export class ApiKeyGuard extends AuthGuard(StrategyType.API_KEY) {} diff --git a/apps/server/src/infra/auth-guard/index.ts b/apps/server/src/infra/auth-guard/index.ts new file mode 100644 index 00000000000..52f405f02fa --- /dev/null +++ b/apps/server/src/infra/auth-guard/index.ts @@ -0,0 +1,9 @@ +export { JwtValidationAdapter } from './adapter'; +export { AuthGuardModule } from './auth-guard.module'; +export { XApiKeyConfig, authConfig } from './config'; +export { CurrentUser, JWT, JwtAuthentication } from './decorator'; +// JwtAuthGuard only exported because api tests still overried this guard. +// Use JwtAuthentication decorator for request validation +export { ApiKeyGuard, JwtAuthGuard, WsJwtAuthGuard } from './guard'; +export { CreateJwtPayload, ICurrentUser, JwtPayload, StrategyType } from './interface'; +export { CurrentUserMapper } from './mapper'; diff --git a/apps/server/src/infra/auth-guard/interface/index.ts b/apps/server/src/infra/auth-guard/interface/index.ts new file mode 100644 index 00000000000..24e791912dc --- /dev/null +++ b/apps/server/src/infra/auth-guard/interface/index.ts @@ -0,0 +1,3 @@ +export * from './jwt-payload'; +export * from './strategy-type'; +export * from './user'; diff --git a/apps/server/src/modules/authentication/interface/jwt-payload.ts b/apps/server/src/infra/auth-guard/interface/jwt-payload.ts similarity index 100% rename from apps/server/src/modules/authentication/interface/jwt-payload.ts rename to apps/server/src/infra/auth-guard/interface/jwt-payload.ts diff --git a/apps/server/src/infra/auth-guard/interface/strategy-type.ts b/apps/server/src/infra/auth-guard/interface/strategy-type.ts new file mode 100644 index 00000000000..6a3259020ce --- /dev/null +++ b/apps/server/src/infra/auth-guard/interface/strategy-type.ts @@ -0,0 +1,5 @@ +export enum StrategyType { + JWT = 'jwt', + WS_JWT = 'wsjwt', + API_KEY = 'api-key', +} diff --git a/apps/server/src/modules/authentication/interface/user.ts b/apps/server/src/infra/auth-guard/interface/user.ts similarity index 100% rename from apps/server/src/modules/authentication/interface/user.ts rename to apps/server/src/infra/auth-guard/interface/user.ts diff --git a/apps/server/src/infra/auth-guard/mapper/config.mapper.spec.ts b/apps/server/src/infra/auth-guard/mapper/config.mapper.spec.ts new file mode 100644 index 00000000000..e1b094b731c --- /dev/null +++ b/apps/server/src/infra/auth-guard/mapper/config.mapper.spec.ts @@ -0,0 +1,420 @@ +import { AuthConfigMapper, JwtConstants } from './config.mapper'; + +const buildNotAStringError = () => new Error(`Type is not a string`); +const buildNotAnObjectError = () => new Error(`Type is not an object.`); + +describe('mapFeathersAuthConfigToAuthConfig', () => { + describe('when input is valid', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const expectedResult: JwtConstants = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + + return { input, expectedResult }; + }; + + it('should map input to JwtConstants', () => { + const { input, expectedResult } = setup(); + + const result: JwtConstants = AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when input is null', () => { + const setup = () => { + const input = null; + const error = buildNotAnObjectError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when input is undefined', () => { + const setup = () => { + const input = undefined; + const error = buildNotAnObjectError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when secret prop is unedfined', () => { + const setup = () => { + const input = { + authConfig: { + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }, + }; + const error = new Error('Object has no secret.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when secret prop is number', () => { + const setup = () => { + const input = { + secret: 123, + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = buildNotAStringError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when jwtOptions prop is unedfined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + }; + const error = new Error('Object has no jwtOptions.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when header prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = new Error('Object has no header.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when typ prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: {}, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = new Error('Object has no typ.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when typ prop is number', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 123 }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = buildNotAStringError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when audience prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = new Error('Object has no audience.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when audience prop is number', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 123, + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = buildNotAStringError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when issuer prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = new Error('Object has no issuer.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when issuer prop is number', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 123, + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + const error = buildNotAStringError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when algorithm prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + expiresIn: '1h', + }, + }; + const error = new Error('Object has no algorithm.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when algorithm prop is number', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 123, + expiresIn: '1h', + }, + }; + const error = new Error('Value is not in strings'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when algorithm prop is not a valid algorithm', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'invalid', + expiresIn: '1h', + }, + }; + const error = new Error('Value is not in strings'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when expiresIn prop is undefined', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + }, + }; + const error = new Error('Object has no expiresIn.'); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); + + describe('when expiresIn prop is number', () => { + const setup = () => { + const input = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: 123, + }, + }; + const error = buildNotAStringError(); + + return { input, error }; + }; + + it('should throw', () => { + const { input, error } = setup(); + + expect(() => AuthConfigMapper.mapFeathersAuthConfigToAuthConfig(input)).toThrow(error); + }); + }); +}); diff --git a/apps/server/src/infra/auth-guard/mapper/config.mapper.ts b/apps/server/src/infra/auth-guard/mapper/config.mapper.ts new file mode 100644 index 00000000000..4247a90b381 --- /dev/null +++ b/apps/server/src/infra/auth-guard/mapper/config.mapper.ts @@ -0,0 +1,101 @@ +import { TypeGuard } from '@shared/common'; + +export type Algorithms = + | 'HS256' + | 'HS384' + | 'HS512' + | 'RS256' + | 'RS384' + | 'RS512' + | 'ES256' + | 'ES384' + | 'ES512' + | 'PS256' + | 'PS384' + | 'PS512' + | 'none'; + +/* + TODO: look at existing keys, vs implemented keys + support: true, + supportUserId, + accountId, + userId, + iat, + exp, + aud: this.aud, + iss: 'feathers', + sub: accountId, + jti: `support_${ObjectId()}`, +*/ +export interface JwtConstants { + secret: string; + jwtOptions: { + header: Header; + audience: string; + issuer: string; + algorithm: Algorithms; + expiresIn: string; + }; +} + +interface Header { + typ: string; +} + +export class AuthConfigMapper { + static mapFeathersAuthConfigToAuthConfig(externalAuthConfig: unknown): JwtConstants { + TypeGuard.checkDefinedObject(externalAuthConfig); + TypeGuard.isDefinedObject(externalAuthConfig); + + TypeGuard.checkKeyInObject(externalAuthConfig, 'secret'); + const secretUnknown = TypeGuard.getValueFromObjectKey(externalAuthConfig, 'secret'); + const secret = TypeGuard.checkString(secretUnknown); + + const jwtOptionsUnknown = TypeGuard.checkKeyInObject(externalAuthConfig, 'jwtOptions'); + const jwtOptions = TypeGuard.checkDefinedObject(jwtOptionsUnknown); + + const headerProp = TypeGuard.checkKeyInObject(jwtOptions, 'header'); + + const typUnknown = TypeGuard.checkKeyInObject(headerProp, 'typ'); + const typ = TypeGuard.checkString(typUnknown); + + const audienceUnknown = TypeGuard.checkKeyInObject(jwtOptions, 'audience'); + const audience = TypeGuard.checkString(audienceUnknown); + + const issuerUnknown = TypeGuard.checkKeyInObject(jwtOptions, 'issuer'); + const issuer = TypeGuard.checkString(issuerUnknown); + + const algorithmUnknown = TypeGuard.checkKeyInObject(jwtOptions, 'algorithm'); + const algorithms: Algorithms[] = [ + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'none', + ]; + const algorithm = TypeGuard.checkStringOfStrings(algorithmUnknown, algorithms); + + const expiresInUnknown = TypeGuard.checkKeyInObject(jwtOptions, 'expiresIn'); + const expiresIn = TypeGuard.checkString(expiresInUnknown); + + return { + secret, + jwtOptions: { + header: { typ }, + audience, + issuer, + algorithm, + expiresIn, + }, + }; + } +} diff --git a/apps/server/src/infra/auth-guard/mapper/current-user.mapper.spec.ts b/apps/server/src/infra/auth-guard/mapper/current-user.mapper.spec.ts new file mode 100644 index 00000000000..5e3592809f8 --- /dev/null +++ b/apps/server/src/infra/auth-guard/mapper/current-user.mapper.spec.ts @@ -0,0 +1,97 @@ +import { currentUserFactory, jwtPayloadFactory, setupEntities } from '@shared/testing'; +import { CreateJwtPayload } from '../interface'; +import { CurrentUserMapper } from './current-user.mapper'; + +describe('CurrentUserMapper', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('jwtToICurrentUser', () => { + describe('when JWT is provided with all claims', () => { + const setup = () => { + const mockJwtPayload = jwtPayloadFactory.build(); + + return { + mockJwtPayload, + }; + }; + + it('should return current user', () => { + const { mockJwtPayload } = setup(); + + const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); + + expect(currentUser).toMatchObject({ + accountId: mockJwtPayload.accountId, + systemId: mockJwtPayload.systemId, + roles: [mockJwtPayload.roles[0]], + schoolId: mockJwtPayload.schoolId, + userId: mockJwtPayload.userId, + impersonated: mockJwtPayload.support, + }); + }); + + it('should return current user with default for isExternalUser', () => { + const { mockJwtPayload } = setup(); + + const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); + + expect(currentUser).toMatchObject({ + isExternalUser: mockJwtPayload.isExternalUser, + }); + }); + }); + + describe('when JWT is provided without optional claims', () => { + const setup = () => { + const mockJwtPayload = jwtPayloadFactory.build(); + + return { + mockJwtPayload, + }; + }; + + it('should return current user', () => { + const { mockJwtPayload } = setup(); + + const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); + + expect(currentUser).toMatchObject({ + accountId: mockJwtPayload.accountId, + roles: [mockJwtPayload.roles[0]], + schoolId: mockJwtPayload.schoolId, + userId: mockJwtPayload.userId, + isExternalUser: true, + }); + }); + + it('should return current user with default for isExternalUser', () => { + const { mockJwtPayload } = setup(); + + const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); + + expect(currentUser).toMatchObject({ + isExternalUser: true, + }); + }); + }); + }); + + describe('mapCurrentUserToCreateJwtPayload', () => { + it('should map current user to create jwt payload', () => { + const currentUser = currentUserFactory.build(); + + const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(currentUser); + + expect(createJwtPayload).toMatchObject({ + accountId: currentUser.accountId, + systemId: currentUser.systemId, + roles: currentUser.roles, + schoolId: currentUser.schoolId, + userId: currentUser.userId, + isExternalUser: false, + }); + }); + }); +}); diff --git a/apps/server/src/infra/auth-guard/mapper/current-user.mapper.ts b/apps/server/src/infra/auth-guard/mapper/current-user.mapper.ts new file mode 100644 index 00000000000..2310f1e6580 --- /dev/null +++ b/apps/server/src/infra/auth-guard/mapper/current-user.mapper.ts @@ -0,0 +1,27 @@ +import { CreateJwtPayload, ICurrentUser, JwtPayload } from '../interface'; + +export class CurrentUserMapper { + static mapCurrentUserToCreateJwtPayload(currentUser: ICurrentUser): CreateJwtPayload { + return { + accountId: currentUser.accountId, + userId: currentUser.userId, + schoolId: currentUser.schoolId, + roles: currentUser.roles, + systemId: currentUser.systemId, + support: currentUser.impersonated, + isExternalUser: currentUser.isExternalUser, + }; + } + + static jwtToICurrentUser(jwtPayload: JwtPayload): ICurrentUser { + return { + accountId: jwtPayload.accountId, + systemId: jwtPayload.systemId, + roles: jwtPayload.roles, + schoolId: jwtPayload.schoolId, + userId: jwtPayload.userId, + impersonated: jwtPayload.support, + isExternalUser: jwtPayload.isExternalUser, + }; + } +} diff --git a/apps/server/src/infra/auth-guard/mapper/index.ts b/apps/server/src/infra/auth-guard/mapper/index.ts new file mode 100644 index 00000000000..6219dac1917 --- /dev/null +++ b/apps/server/src/infra/auth-guard/mapper/index.ts @@ -0,0 +1,2 @@ +export * from './config.mapper'; +export * from './current-user.mapper'; diff --git a/apps/server/src/infra/auth-guard/strategy/index.ts b/apps/server/src/infra/auth-guard/strategy/index.ts new file mode 100644 index 00000000000..32ed1b56e40 --- /dev/null +++ b/apps/server/src/infra/auth-guard/strategy/index.ts @@ -0,0 +1,3 @@ +export * from './jwt.strategy'; +export * from './ws-jwt.strategy'; +export * from './x-api-key.strategy'; diff --git a/apps/server/src/modules/authentication/strategy/jwt.strategy.spec.ts b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts similarity index 78% rename from apps/server/src/modules/authentication/strategy/jwt.strategy.spec.ts rename to apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts index 8a25c259c85..75fa02dd882 100644 --- a/apps/server/src/modules/authentication/strategy/jwt.strategy.spec.ts +++ b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts @@ -4,13 +4,41 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnauthorizedException } from '@nestjs/common'; -import { setupEntities } from '@shared/testing'; -import { jwtConstants } from '../constants'; +import { jwtPayloadFactory, setupEntities } from '@shared/testing'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; -import { jwtPayloadFactory } from '../testing'; +import { JwtValidationAdapter } from '../adapter/jwt-validation.adapter'; import { JwtStrategy } from './jwt.strategy'; +jest.mock('../config', () => { + const authConfig = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + + return { + authConfig, + }; +}); + +const buildAuthConfig = () => { + return { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; +}; + describe('jwt strategy', () => { let validationAdapter: DeepMocked; let strategy: JwtStrategy; @@ -20,7 +48,7 @@ describe('jwt strategy', () => { await setupEntities(); module = await Test.createTestingModule({ - imports: [PassportModule, JwtModule.register(jwtConstants)], + imports: [PassportModule, JwtModule.register(buildAuthConfig())], providers: [ JwtStrategy, { diff --git a/apps/server/src/modules/authentication/strategy/jwt.strategy.ts b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts similarity index 78% rename from apps/server/src/modules/authentication/strategy/jwt.strategy.ts rename to apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts index 84014531093..2373fdf31fa 100644 --- a/apps/server/src/modules/authentication/strategy/jwt.strategy.ts +++ b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts @@ -1,12 +1,11 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-jwt'; import { extractJwtFromHeader } from '@shared/common'; -import { jwtConstants } from '../constants'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { Strategy } from 'passport-jwt'; +import { JwtValidationAdapter } from '../adapter'; +import { authConfig } from '../config'; +import { ICurrentUser, JwtPayload } from '../interface'; import { CurrentUserMapper } from '../mapper'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -14,8 +13,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: extractJwtFromHeader, ignoreExpiration: false, - secretOrKey: jwtConstants.secret, - ...jwtConstants.jwtOptions, + secretOrKey: authConfig.secret, + ...authConfig.jwtOptions, }); } diff --git a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.spec.ts b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts similarity index 78% rename from apps/server/src/modules/authentication/strategy/ws-jwt.strategy.spec.ts rename to apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts index 5d3e12674ad..b095528ca14 100644 --- a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.spec.ts +++ b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts @@ -1,13 +1,41 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { WsException } from '@nestjs/websockets'; -import { setupEntities } from '@shared/testing'; -import { jwtConstants } from '../constants'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; +import { jwtPayloadFactory, setupEntities } from '@shared/testing'; +import { JwtValidationAdapter } from '../adapter'; import { WsJwtStrategy } from './ws-jwt.strategy'; -import { jwtPayloadFactory } from '../testing'; + +jest.mock('../config', () => { + const authConfig = { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; + + return { + authConfig, + }; +}); + +const buildAuthConfig = () => { + return { + secret: 'mysecret', + jwtOptions: { + header: { typ: 'JWT' }, + audience: 'myaudience', + issuer: 'myissuer', + algorithm: 'HS256', + expiresIn: '1h', + }, + }; +}; describe('jwt strategy', () => { let validationAdapter: DeepMocked; @@ -18,7 +46,7 @@ describe('jwt strategy', () => { await setupEntities(); module = await Test.createTestingModule({ - imports: [PassportModule, JwtModule.register(jwtConstants)], + imports: [PassportModule, JwtModule.register(buildAuthConfig())], providers: [ WsJwtStrategy, { diff --git a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.ts similarity index 72% rename from apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts rename to apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.ts index ea76a267da0..8f0e66688b9 100644 --- a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts +++ b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.ts @@ -1,22 +1,21 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { WsException } from '@nestjs/websockets'; -import { ExtractJwt, Strategy } from 'passport-jwt'; import { JwtExtractor } from '@shared/common'; -import { jwtConstants } from '../constants'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { JwtValidationAdapter } from '../adapter'; +import { authConfig } from '../config'; +import { ICurrentUser, JwtPayload, StrategyType } from '../interface'; import { CurrentUserMapper } from '../mapper'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; @Injectable() -export class WsJwtStrategy extends PassportStrategy(Strategy, 'wsjwt') { +export class WsJwtStrategy extends PassportStrategy(Strategy, StrategyType.WS_JWT) { constructor(private readonly jwtValidationAdapter: JwtValidationAdapter) { super({ jwtFromRequest: ExtractJwt.fromExtractors([JwtExtractor.fromCookie('jwt')]), ignoreExpiration: false, - secretOrKey: jwtConstants.secret, - ...jwtConstants.jwtOptions, + secretOrKey: authConfig.secret, + ...authConfig.jwtOptions, }); } diff --git a/apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts b/apps/server/src/infra/auth-guard/strategy/x-api-key.strategy.spec.ts similarity index 100% rename from apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts rename to apps/server/src/infra/auth-guard/strategy/x-api-key.strategy.spec.ts diff --git a/apps/server/src/modules/authentication/strategy/x-api-key.strategy.ts b/apps/server/src/infra/auth-guard/strategy/x-api-key.strategy.ts similarity index 87% rename from apps/server/src/modules/authentication/strategy/x-api-key.strategy.ts rename to apps/server/src/infra/auth-guard/strategy/x-api-key.strategy.ts index 0becc2f8c86..e7aa0939439 100644 --- a/apps/server/src/modules/authentication/strategy/x-api-key.strategy.ts +++ b/apps/server/src/infra/auth-guard/strategy/x-api-key.strategy.ts @@ -1,11 +1,12 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; import Strategy from 'passport-headerapikey'; -import { XApiKeyConfig } from '../config/x-api-key.config'; +import { XApiKeyConfig } from '../config'; +import { StrategyType } from '../interface'; @Injectable() -export class XApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') { +export class XApiKeyStrategy extends PassportStrategy(Strategy, StrategyType.API_KEY) { private readonly allowedApiKeys: string[]; constructor(private readonly configService: ConfigService) { diff --git a/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-body-params.ts b/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-body-params.ts index 1bf892fbe0f..cea578bd6f3 100644 --- a/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-body-params.ts +++ b/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-body-params.ts @@ -54,7 +54,9 @@ export const AuthorizationBodyParamsReferenceType = { SUBMISSIONS: 'submissions', SCHOOL_EXTERNAL_TOOLS: 'school-external-tools', BOARDNODES: 'boardnodes', - CONTEXT_EXTERNAL_TOOLS: 'context-external-tools' + CONTEXT_EXTERNAL_TOOLS: 'context-external-tools', + EXTERNAL_TOOLS: 'external-tools', + INSTANCES: 'instances' } as const; export type AuthorizationBodyParamsReferenceType = typeof AuthorizationBodyParamsReferenceType[keyof typeof AuthorizationBodyParamsReferenceType]; diff --git a/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-context-params.ts b/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-context-params.ts index 055adfae85a..7ff5255cec6 100644 --- a/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-context-params.ts +++ b/apps/server/src/infra/authorization-client/authorization-api-client/models/authorization-context-params.ts @@ -5,14 +5,13 @@ * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. * * The version of the OpenAPI document: 3.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ - // May contain unused imports in some cases // @ts-ignore import type { Action } from './action'; @@ -21,24 +20,21 @@ import type { Action } from './action'; import type { Permission } from './permission'; /** - * + * * @export * @interface AuthorizationContextParams */ export interface AuthorizationContextParams { - /** - * - * @type {Action} - * @memberof AuthorizationContextParams - */ - 'action': Action; - /** - * User permissions that are needed to execute the operation. - * @type {Array} - * @memberof AuthorizationContextParams - */ - 'requiredPermissions': Array; + /** + * + * @type {Action} + * @memberof AuthorizationContextParams + */ + action: Action; + /** + * User permissions that are needed to execute the operation. + * @type {Array} + * @memberof AuthorizationContextParams + */ + requiredPermissions: Array; } - - - diff --git a/apps/server/src/infra/authorization-client/authorization-client.adapter.spec.ts b/apps/server/src/infra/authorization-client/authorization-client.adapter.spec.ts index 35d08e38221..e2b68d63e56 100644 --- a/apps/server/src/infra/authorization-client/authorization-client.adapter.spec.ts +++ b/apps/server/src/infra/authorization-client/authorization-client.adapter.spec.ts @@ -83,7 +83,7 @@ describe(AuthorizationClientAdapter.name, () => { const expectedOptions = { headers: { authorization: `Bearer ${jwtToken}` } }; - await service.checkPermissionsByReference(params); + await service.checkPermissionsByReference(params.referenceType, params.referenceId, params.context); expect(authorizationApi.authorizationReferenceControllerAuthorizeByReference).toHaveBeenCalledWith( params, @@ -95,7 +95,9 @@ describe(AuthorizationClientAdapter.name, () => { it('should resolve', async () => { const { params } = setup({ isAuthorized: true }); - await expect(service.checkPermissionsByReference(params)).resolves.toBeUndefined(); + await expect( + service.checkPermissionsByReference(params.referenceType, params.referenceId, params.context) + ).resolves.toBeUndefined(); }); }); @@ -105,7 +107,9 @@ describe(AuthorizationClientAdapter.name, () => { const expectedError = new AuthorizationForbiddenLoggableException(params); - await expect(service.checkPermissionsByReference(params)).rejects.toThrowError(expectedError); + await expect( + service.checkPermissionsByReference(params.referenceType, params.referenceId, params.context) + ).rejects.toThrowError(expectedError); }); }); }); @@ -132,7 +136,9 @@ describe(AuthorizationClientAdapter.name, () => { const expectedError = new AuthorizationErrorLoggableException(error, params); - await expect(service.checkPermissionsByReference(params)).rejects.toThrowError(expectedError); + await expect( + service.checkPermissionsByReference(params.referenceType, params.referenceId, params.context) + ).rejects.toThrowError(expectedError); }); }); }); @@ -165,7 +171,7 @@ describe(AuthorizationClientAdapter.name, () => { const expectedOptions = { headers: { authorization: `Bearer ${jwtToken}` } }; - await service.hasPermissionsByReference(params); + await service.hasPermissionsByReference(params.referenceType, params.referenceId, params.context); expect(authorizationApi.authorizationReferenceControllerAuthorizeByReference).toHaveBeenCalledWith( params, @@ -176,7 +182,11 @@ describe(AuthorizationClientAdapter.name, () => { it('should return isAuthorized', async () => { const { params, response } = setup(); - const result = await service.hasPermissionsByReference(params); + const result = await service.hasPermissionsByReference( + params.referenceType, + params.referenceId, + params.context + ); expect(result).toEqual(response.data.isAuthorized); }); @@ -216,7 +226,7 @@ describe(AuthorizationClientAdapter.name, () => { const expectedOptions = { headers: { authorization: `Bearer ${jwtToken}` } }; - await adapter.hasPermissionsByReference(params); + await adapter.hasPermissionsByReference(params.referenceType, params.referenceId, params.context); expect(authorizationApi.authorizationReferenceControllerAuthorizeByReference).toHaveBeenCalledWith( params, @@ -259,7 +269,7 @@ describe(AuthorizationClientAdapter.name, () => { const expectedOptions = { headers: { authorization: `Bearer ${jwtToken}` } }; - await adapter.hasPermissionsByReference(params); + await adapter.hasPermissionsByReference(params.referenceType, params.referenceId, params.context); expect(authorizationApi.authorizationReferenceControllerAuthorizeByReference).toHaveBeenCalledWith( params, @@ -294,7 +304,9 @@ describe(AuthorizationClientAdapter.name, () => { const expectedError = new AuthorizationErrorLoggableException(error, params); - await expect(adapter.hasPermissionsByReference(params)).rejects.toThrowError(expectedError); + await expect( + adapter.hasPermissionsByReference(params.referenceType, params.referenceId, params.context) + ).rejects.toThrowError(expectedError); }); }); @@ -320,7 +332,9 @@ describe(AuthorizationClientAdapter.name, () => { const expectedError = new AuthorizationErrorLoggableException(error, params); - await expect(service.hasPermissionsByReference(params)).rejects.toThrowError(expectedError); + await expect( + service.hasPermissionsByReference(params.referenceType, params.referenceId, params.context) + ).rejects.toThrowError(expectedError); }); }); }); diff --git a/apps/server/src/infra/authorization-client/authorization-client.adapter.ts b/apps/server/src/infra/authorization-client/authorization-client.adapter.ts index 711aec8717f..d5e7277321b 100644 --- a/apps/server/src/infra/authorization-client/authorization-client.adapter.ts +++ b/apps/server/src/infra/authorization-client/authorization-client.adapter.ts @@ -1,23 +1,42 @@ import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; +import { extractJwtFromHeader } from '@shared/common'; import { RawAxiosRequestConfig } from 'axios'; import { Request } from 'express'; -import { extractJwtFromHeader } from '@shared/common'; -import { AuthorizationApi, AuthorizationBodyParams } from './authorization-api-client'; +import { + AuthorizationApi, + AuthorizationBodyParamsReferenceType, + AuthorizationContextParams, +} from './authorization-api-client'; import { AuthorizationErrorLoggableException, AuthorizationForbiddenLoggableException } from './error'; @Injectable() export class AuthorizationClientAdapter { constructor(private readonly authorizationApi: AuthorizationApi, @Inject(REQUEST) private request: Request) {} - public async checkPermissionsByReference(params: AuthorizationBodyParams): Promise { - const hasPermission = await this.hasPermissionsByReference(params); + public async checkPermissionsByReference( + referenceType: AuthorizationBodyParamsReferenceType, + referenceId: string, + context: AuthorizationContextParams + ): Promise { + const hasPermission = await this.hasPermissionsByReference(referenceType, referenceId, context); + if (!hasPermission) { - throw new AuthorizationForbiddenLoggableException(params); + throw new AuthorizationForbiddenLoggableException({ referenceType, referenceId, context }); } } - public async hasPermissionsByReference(params: AuthorizationBodyParams): Promise { + public async hasPermissionsByReference( + referenceType: AuthorizationBodyParamsReferenceType, + referenceId: string, + context: AuthorizationContextParams + ): Promise { + const params = { + referenceType, + referenceId, + context, + }; + try { const options = this.createOptionParams(); diff --git a/apps/server/src/infra/authorization-client/authorization-client.module.ts b/apps/server/src/infra/authorization-client/authorization-client.module.ts index fdd6beebaaf..3cd5ba36418 100644 --- a/apps/server/src/infra/authorization-client/authorization-client.module.ts +++ b/apps/server/src/infra/authorization-client/authorization-client.module.ts @@ -3,7 +3,7 @@ import { AuthorizationApi, Configuration, ConfigurationParameters } from './auth import { AuthorizationClientAdapter } from './authorization-client.adapter'; export interface AuthorizationClientConfig extends ConfigurationParameters { - basePath?: string; + basePath: string; } @Module({}) diff --git a/apps/server/src/infra/authorization-client/index.ts b/apps/server/src/infra/authorization-client/index.ts index d67234adb75..3965174bf62 100644 --- a/apps/server/src/infra/authorization-client/index.ts +++ b/apps/server/src/infra/authorization-client/index.ts @@ -1,2 +1,4 @@ +export { Action, AuthorizationBodyParamsReferenceType, AuthorizationContextParams } from './authorization-api-client'; export { AuthorizationClientAdapter } from './authorization-client.adapter'; -export { AuthorizationClientModule } from './authorization-client.module'; +export { AuthorizationClientConfig, AuthorizationClientModule } from './authorization-client.module'; +export { AuthorizationContextBuilder } from './mapper'; diff --git a/apps/server/src/infra/authorization-client/mapper/authorization-context.builder.ts b/apps/server/src/infra/authorization-client/mapper/authorization-context.builder.ts new file mode 100644 index 00000000000..16b55a5564c --- /dev/null +++ b/apps/server/src/infra/authorization-client/mapper/authorization-context.builder.ts @@ -0,0 +1,23 @@ +import { Permission } from '@shared/domain/interface'; +import { Action, AuthorizationContextParams } from '../authorization-api-client'; + +export class AuthorizationContextBuilder { + static build(requiredPermissions: Array, action: Action): AuthorizationContextParams { + return { + action, + requiredPermissions, + }; + } + + static write(requiredPermissions: Permission[]): AuthorizationContextParams { + const context = this.build(requiredPermissions, Action.WRITE); + + return context; + } + + static read(requiredPermissions: Permission[]): AuthorizationContextParams { + const context = this.build(requiredPermissions, Action.READ); + + return context; + } +} diff --git a/apps/server/src/infra/authorization-client/mapper/index.ts b/apps/server/src/infra/authorization-client/mapper/index.ts new file mode 100644 index 00000000000..6f21d79acad --- /dev/null +++ b/apps/server/src/infra/authorization-client/mapper/index.ts @@ -0,0 +1 @@ +export * from './authorization-context.builder'; diff --git a/apps/server/src/infra/socketio/index.ts b/apps/server/src/infra/socketio/index.ts index b4a21ceb03f..74dec42aaf9 100644 --- a/apps/server/src/infra/socketio/index.ts +++ b/apps/server/src/infra/socketio/index.ts @@ -1,3 +1,4 @@ export { Socket } from './types'; export { WsValidationPipe } from './ws-validation.pipe'; export { MongoIoAdapter } from './mongodb-ioadapter'; +export { RedisIoAdapter } from './redis-ioadapter'; diff --git a/apps/server/src/infra/socketio/redis-ioadapter.spec.ts b/apps/server/src/infra/socketio/redis-ioadapter.spec.ts new file mode 100644 index 00000000000..1141634c817 --- /dev/null +++ b/apps/server/src/infra/socketio/redis-ioadapter.spec.ts @@ -0,0 +1,42 @@ +import { LegacyLogger } from '@src/core/logger'; +import { RedisIoAdapter } from './redis-ioadapter'; + +jest.mock('@src/core/logger', () => { + return { + LegacyLogger: jest.fn().mockImplementation(() => { + return { + error: jest.fn(), + }; + }), + }; +}); + +const redisMock = { + on: jest.fn(), + psubscribe: jest.fn(), + subscribe: jest.fn(), +}; + +jest.mock('ioredis', () => { + return { + Redis: jest.fn().mockImplementation(() => redisMock), + }; +}); + +describe('RedisIoAdapter', () => { + const setup = () => { + const legacyLogger = { error: jest.fn() } as unknown as LegacyLogger; + const redisIoAdapter = new RedisIoAdapter(legacyLogger); + return { redisIoAdapter, legacyLogger }; + }; + + describe('createIOServer', () => { + it('should send several actions', () => { + const { redisIoAdapter } = setup(); + + redisIoAdapter.createIOServer(1234); + + expect(redisMock.on).toHaveBeenCalledTimes(6); + }); + }); +}); diff --git a/apps/server/src/infra/socketio/redis-ioadapter.ts b/apps/server/src/infra/socketio/redis-ioadapter.ts new file mode 100644 index 00000000000..d52318d386e --- /dev/null +++ b/apps/server/src/infra/socketio/redis-ioadapter.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { ServerOptions, Server } from 'socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import { Configuration } from '@hpi-schul-cloud/commons'; +import { Redis } from 'ioredis'; + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType | undefined = undefined; + + connectToRedis(): void { + const redisUri = Configuration.has('REDIS_URI') ? (Configuration.get('REDIS_URI') as string) : 'localhost:6379'; + const pubClient = new Redis(redisUri); + const subClient = new Redis(redisUri); + + pubClient.on('error', (err) => { + // istanbul ignore next + process.stdout.write(`pubClient error: ${err.message}`); + }); + + subClient.on('error', (err) => { + // istanbul ignore next + process.stdout.write(`subClient error: ${err.message}`); + }); + + this.adapterConstructor = createAdapter(pubClient, subClient); + } + + createIOServer(port: number, options?: ServerOptions): Server { + this.connectToRedis(); + // istanbul ignore next + if (!this.adapterConstructor) { + throw new Error('Redis adapter is not connected to Redis yet.'); + } + const server = super.createIOServer(port, options) as Server; + // istanbul ignore next + if (server === undefined) { + throw new Error('Unable to create RedisServer'); + } + server.adapter(this.adapterConstructor); + return server; + } +} diff --git a/apps/server/src/infra/socketio/types.ts b/apps/server/src/infra/socketio/types.ts index 6baafe9f34d..c45b542d0ea 100644 --- a/apps/server/src/infra/socketio/types.ts +++ b/apps/server/src/infra/socketio/types.ts @@ -1,4 +1,4 @@ -import { ICurrentUser } from '@src/modules/authentication'; import { Socket as IoSocket } from 'socket.io'; +import { ICurrentUser } from '../auth-guard'; export type Socket = IoSocket & { handshake: { user?: ICurrentUser } }; diff --git a/apps/server/src/modules/account/api/account.controller.ts b/apps/server/src/modules/account/api/account.controller.ts index 3dc573f379c..84743b3057f 100644 --- a/apps/server/src/modules/account/api/account.controller.ts +++ b/apps/server/src/modules/account/api/account.controller.ts @@ -1,8 +1,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; -import { ICurrentUser, Authenticate, CurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { AccountUc } from './account.uc'; import { AccountByIdBodyParams, AccountByIdParams, @@ -16,10 +17,9 @@ import { UpdateMyAccountDto, } from './dto'; import { AccountResponseMapper } from './mapper/account-response.mapper'; -import { AccountUc } from './account.uc'; @ApiTags('Account') -@Authenticate('jwt') +@JwtAuthentication() @Controller('account') export class AccountController { constructor(private readonly accountUc: AccountUc) {} diff --git a/apps/server/src/modules/account/api/account.uc.spec.ts b/apps/server/src/modules/account/api/account.uc.spec.ts index 9a4b227a14e..40e60bc0907 100644 --- a/apps/server/src/modules/account/api/account.uc.spec.ts +++ b/apps/server/src/modules/account/api/account.uc.spec.ts @@ -9,8 +9,7 @@ import { UnauthorizedException } from '@nestjs/common/exceptions/unauthorized.ex import { Role, User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; -import { currentUserFactory } from '@modules/authentication/testing'; +import { currentUserFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Account, AccountSave } from '../domain'; import { AccountEntity } from '../domain/entity/account.entity'; import { AccountService } from '../domain/services'; diff --git a/apps/server/src/modules/account/api/account.uc.ts b/apps/server/src/modules/account/api/account.uc.ts index 744ca430008..87b15f6088f 100644 --- a/apps/server/src/modules/account/api/account.uc.ts +++ b/apps/server/src/modules/account/api/account.uc.ts @@ -1,4 +1,4 @@ -import { ICurrentUser } from '@modules/authentication'; +import { ICurrentUser } from '@infra/auth-guard'; import { AuthorizationService } from '@modules/authorization'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { EntityNotFoundError, ValidationError } from '@shared/common/error'; diff --git a/apps/server/src/modules/account/domain/services/account.service.ts b/apps/server/src/modules/account/domain/services/account.service.ts index 877332b5a53..1bf1cbe2e02 100644 --- a/apps/server/src/modules/account/domain/services/account.service.ts +++ b/apps/server/src/modules/account/domain/services/account.service.ts @@ -288,7 +288,10 @@ export class AccountService extends AbstractAccountService implements DeletionSe }); if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { - if (idmAccount === null || (accountSave.username && idmAccount.username !== accountSave.username)) { + if ( + idmAccount === null || + (accountSave.username && idmAccount.username.toLowerCase() !== accountSave.username.toLowerCase()) + ) { throw new ValidationError('Account could not be updated'); } } diff --git a/apps/server/src/modules/authentication/authentication-config.ts b/apps/server/src/modules/authentication/authentication-config.ts index 9ed4f7250e5..6867177db77 100644 --- a/apps/server/src/modules/authentication/authentication-config.ts +++ b/apps/server/src/modules/authentication/authentication-config.ts @@ -1,5 +1,5 @@ +import { XApiKeyConfig } from '@infra/auth-guard'; import { AccountConfig } from '@modules/account'; -import { XApiKeyConfig } from './config'; export interface AuthenticationConfig extends AccountConfig, XApiKeyConfig { LOGIN_BLOCK_TIME: number; diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index 588d4662c06..72b80ab94c5 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -1,3 +1,4 @@ +import { authConfig, AuthGuardModule } from '@infra/auth-guard'; import { CacheWrapperModule } from '@infra/cache'; import { IdentityManagementModule } from '@infra/identity-management'; import { AccountModule } from '@modules/account'; @@ -9,49 +10,24 @@ import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { LegacySchoolRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { Algorithm, SignOptions } from 'jsonwebtoken'; -import { jwtConstants } from './constants'; -import { JwtValidationAdapter } from './helper/jwt-validation.adapter'; +import { SignOptions } from 'jsonwebtoken'; +import { JwtWhitelistAdapter } from './helper/jwt-whitelist.adapter'; import { AuthenticationService } from './services/authentication.service'; import { LdapService } from './services/ldap.service'; -import { JwtStrategy } from './strategy/jwt.strategy'; import { LdapStrategy } from './strategy/ldap.strategy'; import { LocalStrategy } from './strategy/local.strategy'; import { Oauth2Strategy } from './strategy/oauth2.strategy'; -import { WsJwtStrategy } from './strategy/ws-jwt.strategy'; -import { XApiKeyStrategy } from './strategy/x-api-key.strategy'; - -// values copied from Algorithm definition. Type does not exist at runtime and can't be checked anymore otherwise -const algorithms = [ - 'HS256', - 'HS384', - 'HS512', - 'RS256', - 'RS384', - 'RS512', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'none', -]; - -if (!algorithms.includes(jwtConstants.jwtOptions.algorithm)) { - throw new Error(`${jwtConstants.jwtOptions.algorithm} is not a valid JWT signing algorithm`); -} -const signAlgorithm = jwtConstants.jwtOptions.algorithm as Algorithm; +const { algorithm, audience, expiresIn, issuer, header } = authConfig.jwtOptions; const signOptions: SignOptions = { - algorithm: signAlgorithm, - audience: jwtConstants.jwtOptions.audience, - expiresIn: jwtConstants.jwtOptions.expiresIn, - issuer: jwtConstants.jwtOptions.issuer, - header: { ...jwtConstants.jwtOptions.header, alg: signAlgorithm }, + algorithm, + audience, + expiresIn, + issuer, + header: { ...header, alg: algorithm }, }; const jwtModuleOptions: JwtModuleOptions = { - secret: jwtConstants.secret, + secret: authConfig.secret, signOptions, verifyOptions: signOptions, }; @@ -66,11 +42,9 @@ const jwtModuleOptions: JwtModuleOptions = { RoleModule, IdentityManagementModule, CacheWrapperModule, + AuthGuardModule, ], providers: [ - JwtStrategy, - WsJwtStrategy, - JwtValidationAdapter, UserRepo, LegacySchoolRepo, LocalStrategy, @@ -78,7 +52,7 @@ const jwtModuleOptions: JwtModuleOptions = { LdapService, LdapStrategy, Oauth2Strategy, - XApiKeyStrategy, + JwtWhitelistAdapter, ], exports: [AuthenticationService], }) diff --git a/apps/server/src/modules/authentication/constants.ts b/apps/server/src/modules/authentication/constants.ts deleted file mode 100644 index a673c47f373..00000000000 --- a/apps/server/src/modules/authentication/constants.ts +++ /dev/null @@ -1,32 +0,0 @@ -import externalAuthConfig = require('../../../../../src/services/authentication/configuration'); - -const { authConfig } = externalAuthConfig; - -/* - TODO: look at existing keys, vs implemented keys - support: true, - supportUserId, - accountId, - userId, - iat, - exp, - aud: this.aud, - iss: 'feathers', - sub: accountId, - jti: `support_${ObjectId()}`, -*/ -export interface JwtConstants { - secret: string; - jwtOptions: { - header: { typ: string }; - audience: string; - issuer: string; - algorithm: string; - expiresIn: string; - }; -} - -export const jwtConstants: JwtConstants = { - secret: authConfig.secret as string, - jwtOptions: authConfig.jwtOptions, -}; diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 65d284f938d..926d2c5a5b9 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -1,3 +1,4 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/core'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server/server.module'; @@ -15,7 +16,6 @@ import crypto, { KeyPairKeyObjectResult } from 'crypto'; import jwt from 'jsonwebtoken'; import moment from 'moment'; import request, { Response } from 'supertest'; -import { ICurrentUser } from '../../interface'; import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams, OauthLoginResponse } from '../dto'; const ldapAccountUserName = 'ldapAccountUserName'; diff --git a/apps/server/src/modules/authentication/controllers/login.controller.ts b/apps/server/src/modules/authentication/controllers/login.controller.ts index 1b9e2da9419..42533099454 100644 --- a/apps/server/src/modules/authentication/controllers/login.controller.ts +++ b/apps/server/src/modules/authentication/controllers/login.controller.ts @@ -1,9 +1,9 @@ +import { CurrentUser, ICurrentUser } from '@infra/auth-guard'; import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ForbiddenOperationError, ValidationError } from '@shared/common'; -import { CurrentUser } from '../decorator'; -import type { ICurrentUser, OauthCurrentUser } from '../interface'; +import { StrategyType, type OauthCurrentUser } from '../interface'; import { LoginDto } from '../uc/dto'; import { LoginUc } from '../uc/login.uc'; import { @@ -20,7 +20,7 @@ import { LoginResponseMapper } from './mapper/login-response.mapper'; export class LoginController { constructor(private readonly loginUc: LoginUc) {} - @UseGuards(AuthGuard('ldap')) + @UseGuards(AuthGuard(StrategyType.LDAP)) @HttpCode(HttpStatus.OK) @Post('ldap') @ApiOperation({ summary: 'Starts the login process for users which are authenticated via LDAP' }) @@ -30,14 +30,12 @@ export class LoginController { // Body is not used, but validated and used in the strategy implementation // eslint-disable-next-line @typescript-eslint/no-unused-vars async loginLdap(@CurrentUser() user: ICurrentUser, @Body() _: LdapAuthorizationBodyParams): Promise { - const loginDto: LoginDto = await this.loginUc.getLoginData(user); - - const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); + const response = this.login(user); - return mapped; + return response; } - @UseGuards(AuthGuard('local')) + @UseGuards(AuthGuard(StrategyType.LOCAL)) @HttpCode(HttpStatus.OK) @Post('local') @ApiOperation({ summary: 'Starts the login process for users which are locally managed.' }) @@ -47,14 +45,12 @@ export class LoginController { // Body is not used, but validated and used in the strategy implementation // eslint-disable-next-line @typescript-eslint/no-unused-vars async loginLocal(@CurrentUser() user: ICurrentUser, @Body() _: LocalAuthorizationBodyParams): Promise { - const loginDto: LoginDto = await this.loginUc.getLoginData(user); - - const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); + const response = this.login(user); - return mapped; + return response; } - @UseGuards(AuthGuard('oauth2')) + @UseGuards(AuthGuard(StrategyType.OAUTH2)) @HttpCode(HttpStatus.OK) @Post('oauth2') @ApiOperation({ summary: 'Starts the login process for users which are authenticated via OAuth 2.' }) @@ -73,4 +69,12 @@ export class LoginController { return mapped; } + + private async login(user: ICurrentUser): Promise { + const loginDto: LoginDto = await this.loginUc.getLoginData(user); + + const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); + + return mapped; + } } diff --git a/apps/server/src/modules/authentication/decorator/index.ts b/apps/server/src/modules/authentication/decorator/index.ts deleted file mode 100644 index 9795c6d38ee..00000000000 --- a/apps/server/src/modules/authentication/decorator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './auth.decorator'; diff --git a/apps/server/src/modules/authentication/guard/jwt-auth.guard.ts b/apps/server/src/modules/authentication/guard/jwt-auth.guard.ts deleted file mode 100644 index 2155290edea..00000000000 --- a/apps/server/src/modules/authentication/guard/jwt-auth.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/server/src/modules/authentication/helper/jwt-validation.adapter.spec.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts similarity index 78% rename from apps/server/src/modules/authentication/helper/jwt-validation.adapter.spec.ts rename to apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts index 28803389ae5..2d2662e1dde 100644 --- a/apps/server/src/modules/authentication/helper/jwt-validation.adapter.spec.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts @@ -1,17 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { JwtValidationAdapter } from '@infra/auth-guard/'; +import { CacheService } from '@infra/cache'; +import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; import { ObjectId } from '@mikro-orm/mongodb'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test, TestingModule } from '@nestjs/testing'; -import { CacheService } from '@infra/cache'; -import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; import { feathersRedis } from '@src/imports-from-feathers'; import { Cache } from 'cache-manager'; -import { JwtValidationAdapter } from './jwt-validation.adapter'; +import { JwtWhitelistAdapter } from './jwt-whitelist.adapter'; import RedisMock = require('../../../../../../test/utils/redis/redisMock'); describe('jwt strategy', () => { let module: TestingModule; - let adapter: JwtValidationAdapter; + let jwtWhitelistAdapter: JwtWhitelistAdapter; + let jwtValidationAdapter: JwtValidationAdapter; let cacheManager: DeepMocked; let cacheService: DeepMocked; @@ -20,6 +22,7 @@ describe('jwt strategy', () => { module = await Test.createTestingModule({ providers: [ JwtValidationAdapter, + JwtWhitelistAdapter, { provide: CACHE_MANAGER, useValue: createMock(), @@ -37,7 +40,8 @@ describe('jwt strategy', () => { cacheManager = module.get(CACHE_MANAGER); cacheService = module.get(CacheService); - adapter = module.get(JwtValidationAdapter); + jwtWhitelistAdapter = module.get(JwtWhitelistAdapter); + jwtValidationAdapter = module.get(JwtValidationAdapter); }); afterAll(async () => { @@ -52,16 +56,16 @@ describe('jwt strategy', () => { it('should fail without whitelisted jwt', async () => { const accountId = new ObjectId().toHexString(); const jti = new ObjectId().toHexString(); - await expect(adapter.isWhitelisted(accountId, jti)).rejects.toThrow( + await expect(jwtValidationAdapter.isWhitelisted(accountId, jti)).rejects.toThrow( 'Session was expired due to inactivity - autologout.' ); }); it('should pass when jwt has been whitelisted', async () => { const accountId = new ObjectId().toHexString(); const jti = new ObjectId().toHexString(); - await adapter.addToWhitelist(accountId, jti); + await jwtWhitelistAdapter.addToWhitelist(accountId, jti); // might fail when we would wait more than JWT_TIMEOUT_SECONDS - await adapter.isWhitelisted(accountId, jti); + await jwtValidationAdapter.isWhitelisted(accountId, jti); }); }); @@ -70,7 +74,7 @@ describe('jwt strategy', () => { it('should call the cache manager to delete the entry from the cache', async () => { cacheService.getStoreType.mockReturnValue(CacheStoreType.REDIS); - await adapter.removeFromWhitelist('accountId', 'jti'); + await jwtWhitelistAdapter.removeFromWhitelist('accountId', 'jti'); expect(cacheManager.del).toHaveBeenCalledWith('jwt:accountId:jti'); }); @@ -80,7 +84,7 @@ describe('jwt strategy', () => { it('should do nothing', async () => { cacheService.getStoreType.mockReturnValue(CacheStoreType.MEMORY); - await adapter.removeFromWhitelist('accountId', 'jti'); + await jwtWhitelistAdapter.removeFromWhitelist('accountId', 'jti'); expect(cacheManager.del).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/authentication/helper/jwt-validation.adapter.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts similarity index 61% rename from apps/server/src/modules/authentication/helper/jwt-validation.adapter.ts rename to apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts index 6a67f555c14..9a7b9d81f26 100644 --- a/apps/server/src/modules/authentication/helper/jwt-validation.adapter.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts @@ -1,31 +1,17 @@ -import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject, Injectable } from '@nestjs/common'; import { CacheService } from '@infra/cache'; import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; -import { - addTokenToWhitelist, - createRedisIdentifierFromJwtData, - ensureTokenIsWhitelisted, -} from '@src/imports-from-feathers'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; +import { addTokenToWhitelist, createRedisIdentifierFromJwtData } from '@src/imports-from-feathers'; import { Cache } from 'cache-manager'; @Injectable() -export class JwtValidationAdapter { +export class JwtWhitelistAdapter { constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly cacheService: CacheService ) {} - /** - * When validating a jwt it must be added to a whitelist, here we check this. - * When the jwt is validated, the expiration time will be extended with this call. - * @param accountId users account id - * @param jti jwt id (here required to make jwt identifiers identical in redis) - */ - async isWhitelisted(accountId: string, jti: string): Promise { - await ensureTokenIsWhitelisted({ accountId, jti, privateDevice: false }); - } - async addToWhitelist(accountId: string, jti: string): Promise { const redisIdentifier = createRedisIdentifierFromJwtData(accountId, jti); // eslint-disable-next-line @typescript-eslint/no-unsafe-call diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index 07a4ce9a7ae..81f415d4f46 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -1,7 +1,3 @@ +export { AuthenticationConfig } from './authentication-config'; export { AuthenticationModule } from './authentication.module'; -export { Authenticate, CurrentUser, JWT } from './decorator'; -export { ICurrentUser } from './interface'; export { AuthenticationService } from './services'; -export { XApiKeyConfig } from './config'; -export { WsJwtAuthGuard } from './guard/ws-jwt-auth.guard'; -export { AuthenticationConfig } from './authentication-config'; diff --git a/apps/server/src/modules/authentication/interface/index.ts b/apps/server/src/modules/authentication/interface/index.ts index a9de8109a5b..eb5791d3674 100644 --- a/apps/server/src/modules/authentication/interface/index.ts +++ b/apps/server/src/modules/authentication/interface/index.ts @@ -1,2 +1,2 @@ -export * from './user'; export * from './oauth-current-user'; +export * from './strategy-type'; diff --git a/apps/server/src/modules/authentication/interface/oauth-current-user.ts b/apps/server/src/modules/authentication/interface/oauth-current-user.ts index ddf15e1ca5d..7933cf5479b 100644 --- a/apps/server/src/modules/authentication/interface/oauth-current-user.ts +++ b/apps/server/src/modules/authentication/interface/oauth-current-user.ts @@ -1,4 +1,4 @@ -import { ICurrentUser } from './user'; +import { ICurrentUser } from '@infra/auth-guard'; export interface OauthCurrentUser extends ICurrentUser { /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ diff --git a/apps/server/src/modules/authentication/interface/strategy-type.ts b/apps/server/src/modules/authentication/interface/strategy-type.ts new file mode 100644 index 00000000000..637564c912d --- /dev/null +++ b/apps/server/src/modules/authentication/interface/strategy-type.ts @@ -0,0 +1,5 @@ +export enum StrategyType { + LOCAL = 'local', + LDAP = 'ldap', + OAUTH2 = 'oauth2', +} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index 8fb5e599b30..17a2fb1385c 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -1,10 +1,9 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { ValidationError } from '@shared/common'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Permission, RoleName } from '@shared/domain/interface'; import { roleFactory, schoolEntityFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { ICurrentUser, OauthCurrentUser } from '../interface'; -import { CreateJwtPayload } from '../interface/jwt-payload'; -import { currentUserFactory, jwtPayloadFactory } from '../testing'; +import { OauthCurrentUser } from '../interface'; import { CurrentUserMapper } from './current-user.mapper'; describe('CurrentUserMapper', () => { @@ -218,92 +217,4 @@ describe('CurrentUserMapper', () => { }); }); }); - - describe('jwtToICurrentUser', () => { - describe('when JWT is provided with all claims', () => { - const setup = () => { - const mockJwtPayload = jwtPayloadFactory.build(); - - return { - mockJwtPayload, - }; - }; - - it('should return current user', () => { - const { mockJwtPayload } = setup(); - - const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); - - expect(currentUser).toMatchObject({ - accountId: mockJwtPayload.accountId, - systemId: mockJwtPayload.systemId, - roles: [mockJwtPayload.roles[0]], - schoolId: mockJwtPayload.schoolId, - userId: mockJwtPayload.userId, - impersonated: mockJwtPayload.support, - }); - }); - - it('should return current user with default for isExternalUser', () => { - const { mockJwtPayload } = setup(); - - const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); - - expect(currentUser).toMatchObject({ - isExternalUser: mockJwtPayload.isExternalUser, - }); - }); - }); - - describe('when JWT is provided without optional claims', () => { - const setup = () => { - const mockJwtPayload = jwtPayloadFactory.build(); - - return { - mockJwtPayload, - }; - }; - - it('should return current user', () => { - const { mockJwtPayload } = setup(); - - const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); - - expect(currentUser).toMatchObject({ - accountId: mockJwtPayload.accountId, - roles: [mockJwtPayload.roles[0]], - schoolId: mockJwtPayload.schoolId, - userId: mockJwtPayload.userId, - isExternalUser: true, - }); - }); - - it('should return current user with default for isExternalUser', () => { - const { mockJwtPayload } = setup(); - - const currentUser = CurrentUserMapper.jwtToICurrentUser(mockJwtPayload); - - expect(currentUser).toMatchObject({ - isExternalUser: true, - }); - }); - }); - }); - - describe('mapCurrentUserToCreateJwtPayload', () => { - it('should map current user to create jwt payload', () => { - const currentUser = currentUserFactory.build(); - - const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(currentUser); - - expect(createJwtPayload).toMatchObject({ - accountId: currentUser.accountId, - systemId: currentUser.systemId, - roles: currentUser.roles, - schoolId: currentUser.schoolId, - userId: currentUser.userId, - isExternalUser: false, - }); - }); - }); }); diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts index 1c189877dce..d7a7c889b17 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts @@ -1,9 +1,9 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { ValidationError } from '@shared/common'; import { RoleReference } from '@shared/domain/domainobject'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Role, User } from '@shared/domain/entity'; -import { ICurrentUser, OauthCurrentUser } from '../interface'; -import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; +import { OauthCurrentUser } from '../interface'; export class CurrentUserMapper { static userToICurrentUser(accountId: string, user: User, isExternalUser: boolean, systemId?: string): ICurrentUser { @@ -37,28 +37,4 @@ export class CurrentUserMapper { isExternalUser: true, }; } - - static mapCurrentUserToCreateJwtPayload(currentUser: ICurrentUser): CreateJwtPayload { - return { - accountId: currentUser.accountId, - userId: currentUser.userId, - schoolId: currentUser.schoolId, - roles: currentUser.roles, - systemId: currentUser.systemId, - support: currentUser.impersonated, - isExternalUser: currentUser.isExternalUser, - }; - } - - static jwtToICurrentUser(jwtPayload: JwtPayload): ICurrentUser { - return { - accountId: jwtPayload.accountId, - systemId: jwtPayload.systemId, - roles: jwtPayload.roles, - schoolId: jwtPayload.schoolId, - userId: jwtPayload.userId, - impersonated: jwtPayload.support, - isExternalUser: jwtPayload.isExternalUser, - }; - } } diff --git a/apps/server/src/modules/authentication/services/authentication.service.spec.ts b/apps/server/src/modules/authentication/services/authentication.service.spec.ts index 87fc8403506..ceff02a335b 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.spec.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.spec.ts @@ -4,11 +4,11 @@ import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import { currentUserFactory } from '@shared/testing'; import jwt from 'jsonwebtoken'; import { BruteForceError } from '../errors/brute-force.error'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; +import { JwtWhitelistAdapter } from '../helper/jwt-whitelist.adapter'; import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; -import { currentUserFactory } from '../testing'; import { AuthenticationService } from './authentication.service'; jest.mock('jsonwebtoken'); @@ -17,7 +17,7 @@ describe('AuthenticationService', () => { let module: TestingModule; let authenticationService: AuthenticationService; - let jwtValidationAdapter: DeepMocked; + let jwtWhitelistAdapter: DeepMocked; let accountService: DeepMocked; let jwtService: DeepMocked; @@ -33,8 +33,8 @@ describe('AuthenticationService', () => { providers: [ AuthenticationService, { - provide: JwtValidationAdapter, - useValue: createMock(), + provide: JwtWhitelistAdapter, + useValue: createMock(), }, { provide: JwtService, @@ -51,7 +51,7 @@ describe('AuthenticationService', () => { ], }).compile(); - jwtValidationAdapter = module.get(JwtValidationAdapter); + jwtWhitelistAdapter = module.get(JwtWhitelistAdapter); authenticationService = module.get(AuthenticationService); accountService = module.get(AccountService); jwtService = module.get(JwtService); @@ -135,7 +135,7 @@ describe('AuthenticationService', () => { await authenticationService.removeJwtFromWhitelist('jwt'); - expect(jwtValidationAdapter.removeFromWhitelist).toHaveBeenCalledWith(jwtToken.accountId, jwtToken.jti); + expect(jwtWhitelistAdapter.removeFromWhitelist).toHaveBeenCalledWith(jwtToken.accountId, jwtToken.jti); }); }); @@ -145,7 +145,7 @@ describe('AuthenticationService', () => { await authenticationService.removeJwtFromWhitelist('jwt'); - expect(jwtValidationAdapter.removeFromWhitelist).not.toHaveBeenCalled(); + expect(jwtWhitelistAdapter.removeFromWhitelist).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/server/src/modules/authentication/services/authentication.service.ts b/apps/server/src/modules/authentication/services/authentication.service.ts index b2ecb5f4609..0f842e360ad 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.ts @@ -1,22 +1,21 @@ -import { AccountService, Account } from '@modules/account'; +import { CreateJwtPayload } from '@infra/auth-guard'; +import { Account, AccountService } from '@modules/account'; +import type { ServerConfig } from '@modules/server'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -// invalid import, can produce dependency cycles -import type { ServerConfig } from '@modules/server'; import { randomUUID } from 'crypto'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { BruteForceError, UnauthorizedLoggableException } from '../errors'; -import { CreateJwtPayload } from '../interface/jwt-payload'; -import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; -import { LoginDto } from '../uc/dto'; +import { JwtWhitelistAdapter } from '../helper/jwt-whitelist.adapter'; import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { LoginDto } from '../uc/dto'; @Injectable() export class AuthenticationService { constructor( private readonly jwtService: JwtService, - private readonly jwtValidationAdapter: JwtValidationAdapter, + private readonly jwtWhitelistAdapter: JwtWhitelistAdapter, private readonly accountService: AccountService, private readonly configService: ConfigService ) {} @@ -51,7 +50,7 @@ export class AuthenticationService { }), }); - await this.jwtValidationAdapter.addToWhitelist(user.accountId, jti); + await this.jwtWhitelistAdapter.addToWhitelist(user.accountId, jti); return result; } @@ -60,7 +59,7 @@ export class AuthenticationService { const decodedJwt: JwtPayload | null = jwt.decode(jwtToken, { json: true }); if (this.isValidJwt(decodedJwt)) { - await this.jwtValidationAdapter.removeFromWhitelist(decodedJwt.accountId, decodedJwt.jti); + await this.jwtWhitelistAdapter.removeFromWhitelist(decodedJwt.accountId, decodedJwt.jti); } } diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index 0e537c2ef56..a98dd5295fd 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; import { Account } from '@modules/account'; import { System, SystemService } from '@modules/system'; import { UnauthorizedException } from '@nestjs/common'; @@ -12,7 +13,6 @@ import { legacySchoolDoFactory, schoolEntityFactory, setupEntities, systemFactor import { Logger } from '@src/core/logger'; import { accountDoFactory, defaultTestPassword, defaultTestPasswordHash } from '@src/modules/account/testing'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; -import { ICurrentUser } from '../interface'; import { AuthenticationService } from '../services/authentication.service'; import { LdapService } from '../services/ldap.service'; import { LdapStrategy } from './ldap.strategy'; diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index e922b90c13e..c3f78c82b78 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -1,3 +1,4 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { Account } from '@modules/account'; import { System, SystemService } from '@modules/system'; import { Injectable, UnauthorizedException } from '@nestjs/common'; @@ -9,13 +10,13 @@ import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; import { Strategy } from 'passport-custom'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; -import { ICurrentUser } from '../interface'; +import { StrategyType } from '../interface'; import { CurrentUserMapper } from '../mapper'; import { AuthenticationService } from '../services/authentication.service'; import { LdapService } from '../services/ldap.service'; @Injectable() -export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { +export class LdapStrategy extends PassportStrategy(Strategy, StrategyType.LDAP) { constructor( private readonly systemService: SystemService, private readonly schoolRepo: LegacySchoolRepo, diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.ts b/apps/server/src/modules/authentication/strategy/local.strategy.ts index 30e3f191b2c..6eb806a70d7 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.ts @@ -1,3 +1,4 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { IdentityManagementConfig, IdentityManagementOauthService } from '@infra/identity-management'; import { Account } from '@modules/account'; import { Injectable, UnauthorizedException } from '@nestjs/common'; @@ -7,7 +8,6 @@ import { TypeGuard } from '@shared/common'; import { UserRepo } from '@shared/repo'; import bcrypt from 'bcryptjs'; import { Strategy } from 'passport-local'; -import { ICurrentUser } from '../interface'; import { CurrentUserMapper } from '../mapper'; import { AuthenticationService } from '../services/authentication.service'; @@ -40,6 +40,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { ); const user = await this.userRepo.findById(accountUserId, true); const currentUser = CurrentUserMapper.userToICurrentUser(account.id, user, false); + return currentUser; } diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index daf35dda667..4ebf8315c8a 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -1,14 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; import { Account, AccountService } from '@modules/account'; +import { accountDoFactory } from '@modules/account/testing'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { userDoFactory } from '@shared/testing'; -import { accountDoFactory } from '@modules/account/testing'; -import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { OauthCurrentUser } from '../interface'; import { SchoolInMigrationLoggableException } from '../loggable'; diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 697ffa9d1e3..8f9831b3dac 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,17 +1,18 @@ -import { AccountService, Account } from '@modules/account'; +import { ICurrentUser } from '@infra/auth-guard'; +import { Account, AccountService } from '@modules/account'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; -import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { OauthCurrentUser, StrategyType } from '../interface'; import { AccountNotFoundLoggableException, SchoolInMigrationLoggableException } from '../loggable'; -import { CurrentUserMapper } from '../mapper'; import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { CurrentUserMapper } from '../mapper'; @Injectable() -export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { +export class Oauth2Strategy extends PassportStrategy(Strategy, StrategyType.OAUTH2) { constructor(private readonly oauthService: OAuthService, private readonly accountService: AccountService) { super(); } @@ -42,6 +43,7 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { systemId, tokenDto.idToken ); + return currentUser; } } diff --git a/apps/server/src/modules/authentication/testing/index.ts b/apps/server/src/modules/authentication/testing/index.ts deleted file mode 100644 index 12983c433ca..00000000000 --- a/apps/server/src/modules/authentication/testing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './currentuser.factory'; -export * from './jwtpayload.factory'; diff --git a/apps/server/src/modules/authentication/uc/login.uc.ts b/apps/server/src/modules/authentication/uc/login.uc.ts index a676e0d79d3..41a0a3e0ec4 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.ts @@ -1,7 +1,5 @@ +import { CreateJwtPayload, CurrentUserMapper, ICurrentUser } from '@infra/auth-guard'; import { Injectable } from '@nestjs/common'; -import { ICurrentUser } from '../interface'; -import { CreateJwtPayload } from '../interface/jwt-payload'; -import { CurrentUserMapper } from '../mapper'; import { AuthenticationService } from '../services'; import { LoginDto } from './dto'; diff --git a/apps/server/src/modules/authorization/api/authorization-reference.controller.ts b/apps/server/src/modules/authorization/api/authorization-reference.controller.ts index 97906f1948b..07fbd3f9942 100644 --- a/apps/server/src/modules/authorization/api/authorization-reference.controller.ts +++ b/apps/server/src/modules/authorization/api/authorization-reference.controller.ts @@ -1,11 +1,11 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { AuthorizationReferenceUc } from './authorization-reference.uc'; import { AuthorizationBodyParams, AuthorizedReponse } from './dto'; -@Authenticate('jwt') +@JwtAuthentication() @ApiTags('Authorization') @Controller('authorization') export class AuthorizationReferenceController { diff --git a/apps/server/src/modules/board/board-collaboration.module.ts b/apps/server/src/modules/board/board-collaboration.module.ts index d53c3b46113..46d19a462e0 100644 --- a/apps/server/src/modules/board/board-collaboration.module.ts +++ b/apps/server/src/modules/board/board-collaboration.module.ts @@ -1,3 +1,4 @@ +import { AuthGuardModule } from '@infra/auth-guard'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { defaultMikroOrmOptions } from '@modules/server'; import { Module } from '@nestjs/common'; @@ -10,7 +11,6 @@ import { AuthorizationModule } from '../authorization'; import { config } from './board-collaboration.config'; import { BoardWsApiModule } from './board-ws-api.module'; import { BoardModule } from './board.module'; -import { AuthenticationModule } from '../authentication'; @Module({ imports: [ @@ -27,8 +27,8 @@ import { AuthenticationModule } from '../authentication'; }), BoardModule, AuthorizationModule, - AuthenticationModule, BoardWsApiModule, + AuthGuardModule, ], providers: [], exports: [], diff --git a/apps/server/src/modules/board/board-collaboration.testing.module.ts b/apps/server/src/modules/board/board-collaboration.testing.module.ts index 60fa20fd169..82c4b46678f 100644 --- a/apps/server/src/modules/board/board-collaboration.testing.module.ts +++ b/apps/server/src/modules/board/board-collaboration.testing.module.ts @@ -6,12 +6,11 @@ import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { MongoMemoryDatabaseModule } from '@src/infra/database'; import { RabbitMQWrapperModule } from '@src/infra/rabbitmq'; -import { AuthenticationModule } from '../authentication'; import { AuthenticationApiModule } from '../authentication/authentication-api.module'; import { AuthorizationModule } from '../authorization'; +import { config as boardCollaborationConfig } from './board-collaboration.config'; import { BoardWsApiModule } from './board-ws-api.module'; import { BoardModule } from './board.module'; -import { config as boardCollaborationConfig } from './board-collaboration.config'; const config = () => { return { ...serverConfig(), ...boardCollaborationConfig() }; @@ -28,7 +27,6 @@ const config = () => { }), BoardModule, AuthorizationModule, - AuthenticationModule, AuthenticationApiModule, BoardWsApiModule, ], diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 884db4ee05a..798366fb0b7 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -16,6 +16,7 @@ import { BoardNodeAuthorizableService, BoardNodeService, ColumnBoardService, + ContextExternalToolDeletedEventHandlerService, MediaBoardService, UserDeletedEventHandlerService, } from './service'; @@ -60,6 +61,7 @@ import { ColumnBoardReferenceService, ColumnBoardTitleService, UserDeletedEventHandlerService, + ContextExternalToolDeletedEventHandlerService, // TODO replace by import of MediaBoardModule (fix dependency cycle) MediaBoardService, ], diff --git a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts index 4b21832f71e..c347396e8e5 100644 --- a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts @@ -1,6 +1,5 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -8,8 +7,8 @@ import { ApiValidationError } from '@shared/common'; import { cleanupCollections, courseFactory, mapUserToCurrentUser, userFactory } from '@shared/testing'; import { Request } from 'express'; import request from 'supertest'; -import { columnBoardEntityFactory, columnEntityFactory } from '../../testing'; import { BoardExternalReferenceType, ContentElementType } from '../../domain'; +import { columnBoardEntityFactory, columnEntityFactory } from '../../testing'; import { CardResponse } from '../dto'; const baseRouteName = '/columns'; diff --git a/apps/server/src/modules/board/controller/api-test/column-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/column-create.api.spec.ts index c948f189ff3..fea177dfb79 100644 --- a/apps/server/src/modules/board/controller/api-test/column-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/column-create.api.spec.ts @@ -1,6 +1,5 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -8,8 +7,8 @@ import { ApiValidationError } from '@shared/common'; import { cleanupCollections, courseFactory, mapUserToCurrentUser, userFactory } from '@shared/testing'; import { Request } from 'express'; import request from 'supertest'; -import { columnBoardEntityFactory } from '../../testing'; import { BoardExternalReferenceType } from '../../domain'; +import { columnBoardEntityFactory } from '../../testing'; import { ColumnResponse } from '../dto'; const baseRouteName = '/boards'; diff --git a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts index 12921eff7d2..8f4619fa0b0 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts @@ -1,16 +1,16 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ServerTestModule } from '@modules/server/server.module'; +import { DrawingElementAdapterService } from '@modules/tldraw-client'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { cleanupCollections, courseFactory, mapUserToCurrentUser, userFactory } from '@shared/testing'; import { Request } from 'express'; import request from 'supertest'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DrawingElementAdapterService } from '@modules/tldraw-client'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BoardExternalReferenceType } from '../../domain'; import { BoardNodeEntity } from '../../repo'; import { cardEntityFactory, @@ -19,7 +19,6 @@ import { drawingElementEntityFactory, richTextElementEntityFactory, } from '../../testing'; -import { BoardExternalReferenceType } from '../../domain'; const baseRouteName = '/elements'; diff --git a/apps/server/src/modules/board/controller/board-submission.controller.ts b/apps/server/src/modules/board/controller/board-submission.controller.ts index f62ecd8a65f..a7500441c46 100644 --- a/apps/server/src/modules/board/controller/board-submission.controller.ts +++ b/apps/server/src/modules/board/controller/board-submission.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -28,7 +28,7 @@ import { SubmissionsResponse } from './dto/submission-item/submissions.response' import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Submission') -@Authenticate('jwt') +@JwtAuthentication() @Controller('board-submissions') export class BoardSubmissionController { constructor( diff --git a/apps/server/src/modules/board/controller/board.controller.ts b/apps/server/src/modules/board/controller/board.controller.ts index a341e387e50..120ce0863d4 100644 --- a/apps/server/src/modules/board/controller/board.controller.ts +++ b/apps/server/src/modules/board/controller/board.controller.ts @@ -1,4 +1,5 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; import { Body, Controller, @@ -13,7 +14,6 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError, RequestTimeout } from '@shared/common'; -import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; import { BoardUc } from '../uc'; import { BoardResponse, @@ -28,7 +28,7 @@ import { BoardContextResponse } from './dto/board/board-context.reponse'; import { BoardResponseMapper, ColumnResponseMapper, CreateBoardResponseMapper } from './mapper'; @ApiTags('Board') -@Authenticate('jwt') +@JwtAuthentication() @Controller('boards') export class BoardController { constructor(private readonly boardUc: BoardUc) {} diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index 341d1e85dc9..639a5175a3b 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -22,6 +22,7 @@ import { CardListResponse, CardUrlParams, CreateContentElementBodyParams, + DeletedElementResponse, DrawingElementResponse, ExternalToolElementResponse, FileElementResponse, @@ -35,7 +36,7 @@ import { SetHeightBodyParams } from './dto/board/set-height.body.params'; import { CardResponseMapper, ContentElementResponseFactory } from './mapper'; @ApiTags('Board Card') -@Authenticate('jwt') +@JwtAuthentication() @Controller('cards') export class CardController { constructor(private readonly columnUc: ColumnUc, private readonly cardUc: CardUc) {} @@ -121,7 +122,9 @@ export class CardController { FileElementResponse, LinkElementResponse, RichTextElementResponse, - SubmissionContainerElementResponse + SubmissionContainerElementResponse, + DrawingElementResponse, + DeletedElementResponse ) @ApiResponse({ status: 201, @@ -133,6 +136,7 @@ export class CardController { { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, + { $ref: getSchemaPath(DeletedElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/column.controller.ts b/apps/server/src/modules/board/controller/column.controller.ts index 9a3da989159..a7ddc57fcc1 100644 --- a/apps/server/src/modules/board/controller/column.controller.ts +++ b/apps/server/src/modules/board/controller/column.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -19,7 +19,7 @@ import { CreateCardBodyParams } from './dto/card/create-card.body.params'; import { CardResponseMapper } from './mapper'; @ApiTags('Board Column') -@Authenticate('jwt') +@JwtAuthentication() @Controller('columns') export class ColumnController { constructor(private readonly boardUc: BoardUc, private readonly columnUc: ColumnUc) {} diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index 31f8049e835..aa641fdd736 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -3,6 +3,7 @@ import { DecodeHtmlEntities } from '@shared/controller'; import { AnyContentElementResponse, CollaborativeTextEditorElementResponse, + DeletedElementResponse, DrawingElementResponse, ExternalToolElementResponse, FileElementResponse, @@ -20,7 +21,8 @@ import { VisibilitySettingsResponse } from './visibility-settings.response'; RichTextElementResponse, DrawingElementResponse, SubmissionContainerElementResponse, - CollaborativeTextEditorElementResponse + CollaborativeTextEditorElementResponse, + DeletedElementResponse ) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { @@ -55,6 +57,7 @@ export class CardResponse { { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, { $ref: getSchemaPath(CollaborativeTextEditorElementResponse) }, + { $ref: getSchemaPath(DeletedElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index fa77ed11755..dbe2adc1e01 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,4 +1,5 @@ import { CollaborativeTextEditorElementResponse } from './collaborative-text-editor-element.response'; +import { DeletedElementResponse } from './deleted-element.response'; import { DrawingElementResponse } from './drawing-element.response'; import { ExternalToolElementResponse } from './external-tool-element.response'; import { FileElementResponse } from './file-element.response'; @@ -13,7 +14,8 @@ export type AnyContentElementResponse = | SubmissionContainerElementResponse | ExternalToolElementResponse | DrawingElementResponse - | CollaborativeTextEditorElementResponse; + | CollaborativeTextEditorElementResponse + | DeletedElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/deleted-element.response.ts b/apps/server/src/modules/board/controller/dto/element/deleted-element.response.ts new file mode 100644 index 00000000000..785f02616b8 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/deleted-element.response.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '../../../domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class DeletedElementContent { + constructor(props: DeletedElementContent) { + this.title = props.title; + this.deletedElementType = props.deletedElementType; + } + + @ApiProperty() + title: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + deletedElementType: ContentElementType; +} + +export class DeletedElementResponse { + constructor(props: DeletedElementResponse) { + this.id = props.id; + this.type = props.type; + this.content = props.content; + this.timestamps = props.timestamps; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.DELETED; + + @ApiProperty() + content: DeletedElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; +} diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 73c8d08f93c..0a85fb2c699 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -8,3 +8,4 @@ export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; export * from './update-element-content.body.params'; +export * from './deleted-element.response'; diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index ec1fbd74aed..fc572d39e46 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -39,7 +39,7 @@ import { import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Element') -@Authenticate('jwt') +@JwtAuthentication() @Controller('elements') export class ElementController { constructor(private readonly cardUc: CardUc, private readonly elementUc: ElementUc) {} diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index a4bad1ef915..d5c4942a777 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,15 +1,17 @@ import { NotImplementedException } from '@nestjs/common'; import { - fileElementFactory, + deletedElementFactory, drawingElementFactory, + fileElementFactory, linkElementFactory, richTextElementFactory, submissionContainerElementFactory, } from '../../testing'; import { + DeletedElementResponse, + DrawingElementResponse, FileElementResponse, LinkElementResponse, - DrawingElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, } from '../dto'; @@ -55,6 +57,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(SubmissionContainerElementResponse); }); + it('should return instance of DeletedElementResponse', () => { + const drawingElement = deletedElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(drawingElement); + + expect(result).toBeInstanceOf(DeletedElementResponse); + }); + it('should throw NotImplementedException', () => { // @ts-expect-error check unknown type expect(() => ContentElementResponseFactory.mapToResponse('UNKNOWN')).toThrow(NotImplementedException); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 4047a921c98..dec7e12420e 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -3,12 +3,13 @@ import { AnyBoardNode, FileElement, RichTextElement } from '../../domain'; import { AnyContentElementResponse, FileElementResponse, - RichTextElementResponse, isFileElementResponse, isRichTextElementResponse, + RichTextElementResponse, } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; import { CollaborativeTextEditorElementResponseMapper } from './collaborative-text-editor-element-response.mapper'; +import { DeletedElementResponseMapper } from './deleted-element-response.mapper'; import { DrawingElementResponseMapper } from './drawing-element-response.mapper'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; @@ -25,6 +26,7 @@ export class ContentElementResponseFactory { SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), CollaborativeTextEditorElementResponseMapper.getInstance(), + DeletedElementResponseMapper.getInstance(), ]; static mapToResponse(element: AnyBoardNode): AnyContentElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/deleted-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/deleted-element-response.mapper.ts new file mode 100644 index 00000000000..26713849366 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/deleted-element-response.mapper.ts @@ -0,0 +1,30 @@ +import { ContentElementType, DeletedElement } from '../../domain'; +import { DeletedElementContent, DeletedElementResponse, TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class DeletedElementResponseMapper implements BaseResponseMapper { + private static instance: DeletedElementResponseMapper; + + public static getInstance(): DeletedElementResponseMapper { + if (!DeletedElementResponseMapper.instance) { + DeletedElementResponseMapper.instance = new DeletedElementResponseMapper(); + } + + return DeletedElementResponseMapper.instance; + } + + mapToResponse(element: DeletedElement): DeletedElementResponse { + const result = new DeletedElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.DELETED, + content: new DeletedElementContent({ title: element.title, deletedElementType: element.deletedElementType }), + }); + + return result; + } + + canMap(element: unknown): boolean { + return element instanceof DeletedElement; + } +} diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 2d5e1b49937..980c5be1e45 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -10,3 +10,4 @@ export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; +export * from './deleted-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/media-board/media-board.controller.ts b/apps/server/src/modules/board/controller/media-board/media-board.controller.ts index 2b331e05c16..a3f8c401bc2 100644 --- a/apps/server/src/modules/board/controller/media-board/media-board.controller.ts +++ b/apps/server/src/modules/board/controller/media-board/media-board.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -36,7 +36,7 @@ import { import { MediaAvailableLineResponseMapper, MediaBoardResponseMapper, MediaLineResponseMapper } from './mapper'; @ApiTags('Media Board') -@Authenticate('jwt') +@JwtAuthentication() @Controller('media-boards') export class MediaBoardController { constructor( diff --git a/apps/server/src/modules/board/controller/media-board/media-element.controller.ts b/apps/server/src/modules/board/controller/media-board/media-element.controller.ts index 1f9df2d91ff..49c0f454367 100644 --- a/apps/server/src/modules/board/controller/media-board/media-element.controller.ts +++ b/apps/server/src/modules/board/controller/media-board/media-element.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -32,7 +32,7 @@ import { import { MediaExternalToolElementResponseMapper } from './mapper'; @ApiTags('Media Element') -@Authenticate('jwt') +@JwtAuthentication() @Controller('media-elements') export class MediaElementController { constructor(private readonly mediaElementUc: MediaElementUc) {} diff --git a/apps/server/src/modules/board/controller/media-board/media-line.controller.ts b/apps/server/src/modules/board/controller/media-board/media-line.controller.ts index 1305263d3aa..65e396b8842 100644 --- a/apps/server/src/modules/board/controller/media-board/media-line.controller.ts +++ b/apps/server/src/modules/board/controller/media-board/media-line.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -25,7 +25,7 @@ import { MoveColumnBodyParams, RenameBodyParams } from '../dto'; import { CollapsableBodyParams, ColorBodyParams, LineUrlParams } from './dto'; @ApiTags('Media Line') -@Authenticate('jwt') +@JwtAuthentication() @Controller('media-lines') export class MediaLineController { constructor(private readonly mediaLineUc: MediaLineUc) {} diff --git a/apps/server/src/modules/board/domain/board-node.do.ts b/apps/server/src/modules/board/domain/board-node.do.ts index 7c1f50a0092..1a7a74bbff0 100644 --- a/apps/server/src/modules/board/domain/board-node.do.ts +++ b/apps/server/src/modules/board/domain/board-node.do.ts @@ -13,6 +13,25 @@ export abstract class BoardNode extends DomainObject { + let element: DeletedElement; + + const boardNodeProps: BoardNodeProps = { + id: new ObjectId().toHexString(), + path: '', + level: 1, + position: 1, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + element = new DeletedElement({ + ...boardNodeProps, + deletedElementType: ContentElementType.EXTERNAL_TOOL, + title: 'Old Tool', + }); + }); + + it('should return title', () => { + expect(element.title).toEqual('Old Tool'); + }); + + it('should set title', () => { + const title = 'Title'; + + element.title = title; + + expect(element.title).toEqual(title); + }); + + it('should return deletedElementType', () => { + expect(element.deletedElementType).toEqual(ContentElementType.EXTERNAL_TOOL); + }); + + it('should set deletedElementType', () => { + const deletedElementType = ContentElementType.FILE; + + element.deletedElementType = deletedElementType; + + expect(element.deletedElementType).toEqual(deletedElementType); + }); + + it('should not have child', () => { + expect(element.canHaveChild()).toEqual(false); + }); +}); diff --git a/apps/server/src/modules/board/domain/deleted-element.do.ts b/apps/server/src/modules/board/domain/deleted-element.do.ts new file mode 100644 index 00000000000..b274e0a32f1 --- /dev/null +++ b/apps/server/src/modules/board/domain/deleted-element.do.ts @@ -0,0 +1,27 @@ +import { BoardNode } from './board-node.do'; +import type { ContentElementType, DeletedElementProps } from './types'; + +export class DeletedElement extends BoardNode { + get title(): string { + return this.props.title; + } + + set title(value: string) { + this.props.title = value; + } + + get deletedElementType(): ContentElementType { + return this.props.deletedElementType; + } + + set deletedElementType(value: ContentElementType) { + this.props.deletedElementType = value; + } + + canHaveChild(): boolean { + return false; + } +} + +export const isDeletedElement = (reference: unknown): reference is DeletedElement => + reference instanceof DeletedElement; diff --git a/apps/server/src/modules/board/domain/index.ts b/apps/server/src/modules/board/domain/index.ts index efc5293a454..a052556cf7f 100644 --- a/apps/server/src/modules/board/domain/index.ts +++ b/apps/server/src/modules/board/domain/index.ts @@ -16,3 +16,4 @@ export * from './submission-item.do'; export * from './path-utils'; export * from './types'; export * from './type-mapping'; +export * from './deleted-element.do'; diff --git a/apps/server/src/modules/board/domain/media-board/types/any-media-board-node.ts b/apps/server/src/modules/board/domain/media-board/types/any-media-board-node.ts index 7ea3d2f2c76..a6969807633 100644 --- a/apps/server/src/modules/board/domain/media-board/types/any-media-board-node.ts +++ b/apps/server/src/modules/board/domain/media-board/types/any-media-board-node.ts @@ -3,13 +3,3 @@ import type { MediaExternalToolElement } from '../media-external-tool-element.do import type { MediaLine } from '../media-line.do'; export type AnyMediaBoardNode = MediaBoard | MediaLine | MediaExternalToolElement; - -// TODO remove if not needed -// export type AnyMediaBoardNode = MediaExternalToolElement; -/* -export const isAnyMediaContentElement = (element: AnyMediaBoardNode): element is AnyMediaBoardNode => { - const result: boolean = element instanceof MediaExternalToolElement; - - return result; -}; -*/ diff --git a/apps/server/src/modules/board/domain/type-mapping.ts b/apps/server/src/modules/board/domain/type-mapping.ts index 983e9a2d332..a4a3b08ab8e 100644 --- a/apps/server/src/modules/board/domain/type-mapping.ts +++ b/apps/server/src/modules/board/domain/type-mapping.ts @@ -3,6 +3,7 @@ import { Card } from './card.do'; import { CollaborativeTextEditorElement } from './collaborative-text-editor.do'; import { ColumnBoard } from './colum-board.do'; import { Column } from './column.do'; +import { DeletedElement } from './deleted-element.do'; import { DrawingElement } from './drawing-element.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -30,6 +31,7 @@ const BoardNodeTypeToConstructor = { [BoardNodeType.RICH_TEXT_ELEMENT]: RichTextElement, [BoardNodeType.SUBMISSION_CONTAINER_ELEMENT]: SubmissionContainerElement, [BoardNodeType.SUBMISSION_ITEM]: SubmissionItem, + [BoardNodeType.DELETED_ELEMENT]: DeletedElement, } as const; export const getBoardNodeConstructor = (type: T): typeof BoardNodeTypeToConstructor[T] => diff --git a/apps/server/src/modules/board/domain/types/any-content-element.ts b/apps/server/src/modules/board/domain/types/any-content-element.ts index 1751287d35e..c8f6cae8bb3 100644 --- a/apps/server/src/modules/board/domain/types/any-content-element.ts +++ b/apps/server/src/modules/board/domain/types/any-content-element.ts @@ -1,4 +1,5 @@ import { type CollaborativeTextEditorElement, isCollaborativeTextEditorElement } from '../collaborative-text-editor.do'; +import { type DeletedElement, isDeletedElement } from '../deleted-element.do'; import { type DrawingElement, isDrawingElement } from '../drawing-element.do'; import { type ExternalToolElement, isExternalToolElement } from '../external-tool-element.do'; import { type FileElement, isFileElement } from '../file-element.do'; @@ -14,7 +15,8 @@ export type AnyContentElement = | FileElement | LinkElement | RichTextElement - | SubmissionContainerElement; + | SubmissionContainerElement + | DeletedElement; export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyContentElement => { const result = @@ -24,7 +26,8 @@ export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyConte isFileElement(boardNode) || isLinkElement(boardNode) || isRichTextElement(boardNode) || - isSubmissionContainerElement(boardNode); + isSubmissionContainerElement(boardNode) || + isDeletedElement(boardNode); return result; }; diff --git a/apps/server/src/modules/board/domain/types/board-node-props.ts b/apps/server/src/modules/board/domain/types/board-node-props.ts index a89dab6a802..f8307160192 100644 --- a/apps/server/src/modules/board/domain/types/board-node-props.ts +++ b/apps/server/src/modules/board/domain/types/board-node-props.ts @@ -1,8 +1,9 @@ import type { EntityId, InputFormat } from '@shared/domain/types'; +import type { MediaBoardColors } from '../media-board'; import type { AnyBoardNode } from './any-board-node'; import type { BoardExternalReference } from './board-external-reference'; import { BoardLayout } from './board-layout.enum'; -import type { MediaBoardColors } from '../media-board'; +import { ContentElementType } from './content-element-type.enum'; export interface BoardNodeProps { id: EntityId; @@ -65,6 +66,11 @@ export interface SubmissionItemProps extends BoardNodeProps { userId: EntityId; } +export interface DeletedElementProps extends BoardNodeProps { + title: string; + deletedElementType: ContentElementType; +} + export interface MediaBoardProps extends BoardNodeProps { context: BoardExternalReference; backgroundColor: MediaBoardColors; diff --git a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts index 7fde3710af1..71523c41749 100644 --- a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts @@ -10,6 +10,7 @@ export enum BoardNodeType { SUBMISSION_ITEM = 'submission-item', EXTERNAL_TOOL = 'external-tool', COLLABORATIVE_TEXT_EDITOR = 'collaborative-text-editor', + DELETED_ELEMENT = 'deleted-element', MEDIA_BOARD = 'media-board', MEDIA_LINE = 'media-line', diff --git a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts index 5127c9b4376..773c63fdbe9 100644 --- a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts @@ -6,4 +6,5 @@ export enum ContentElementType { SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', COLLABORATIVE_TEXT_EDITOR = 'collaborativeTextEditor', + DELETED = 'deleted', } diff --git a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts index c9a6cc7163b..10d5c3cc287 100644 --- a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts +++ b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts @@ -1,6 +1,6 @@ +import { WsJwtAuthGuard } from '@infra/auth-guard'; import { Socket, WsValidationPipe } from '@infra/socketio'; import { MikroORM, UseRequestContext } from '@mikro-orm/core'; -import { WsJwtAuthGuard } from '@modules/authentication'; import { UseGuards, UsePipes } from '@nestjs/common'; import { OnGatewayDisconnect, @@ -9,24 +9,26 @@ import { WebSocketServer, WsException, } from '@nestjs/websockets'; -import { Server } from 'socket.io'; import { EntityId } from '@shared/domain/types'; +import { Server } from 'socket.io'; import { BoardResponseMapper, CardResponseMapper, ColumnResponseMapper, ContentElementResponseFactory, } from '../controller/mapper'; +import { AnyBoardNode } from '../domain'; import { MetricsService } from '../metrics/metrics.service'; import { TrackExecutionTime } from '../metrics/track-execution-time.decorator'; import { BoardUc, CardUc, ColumnUc, ElementUc } from '../uc'; +import BoardCollaborationConfiguration from './dto/board-collaboration-config'; import { CreateCardMessageParams } from './dto/create-card.message.param'; import { CreateColumnMessageParams } from './dto/create-column.message.param'; import { CreateContentElementMessageParams } from './dto/create-content-element.message.param'; import { DeleteBoardMessageParams } from './dto/delete-board.message.param'; import { DeleteCardMessageParams } from './dto/delete-card.message.param'; -import { DeleteContentElementMessageParams } from './dto/delete-content-element.message.param'; import { DeleteColumnMessageParams } from './dto/delete-column.message.param'; +import { DeleteContentElementMessageParams } from './dto/delete-content-element.message.param'; import { FetchBoardMessageParams } from './dto/fetch-board.message.param'; import { FetchCardsMessageParams } from './dto/fetch-cards.message.param'; import { MoveCardMessageParams } from './dto/move-card.message.param'; @@ -38,8 +40,6 @@ import { UpdateCardHeightMessageParams } from './dto/update-card-height.message. import { UpdateCardTitleMessageParams } from './dto/update-card-title.message.param'; import { UpdateColumnTitleMessageParams } from './dto/update-column-title.message.param'; import { UpdateContentElementMessageParams } from './dto/update-content-element.message.param'; -import BoardCollaborationConfiguration from './dto/board-collaboration-config'; -import { AnyBoardNode } from '../domain'; @UsePipes(new WsValidationPipe()) @WebSocketGateway(BoardCollaborationConfiguration.websocket) @@ -197,6 +197,7 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { ...data, newColumn, }; + await emitter.joinRoom(column); emitter.emitToClientAndRoom(responsePayload, column); // payload needs to be returned to allow the client to do sequential operation diff --git a/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts b/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts new file mode 100644 index 00000000000..cc82db40d3e --- /dev/null +++ b/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-process-env */ +import { CreateBoardLoadTest, SocketConfiguration } from './types'; +import { viewersClass, collaborativeClass } from './helper/class-definitions'; +import { SocketConnectionManager } from './socket-connection-manager'; +import { LoadtestRunner } from './loadtest-runner'; +import { BoardLoadTest } from './board-load-test'; + +describe('Board Collaboration Load Test', () => { + it('should run a basic load test', async () => { + const { COURSE_ID, TOKEN, TARGET_URL } = process.env; + const viewerClassesAmount = process.env.VIEWER_CLASSES ? parseInt(process.env.VIEWER_CLASSES, 10) : 20; + const collabClassesAmount = process.env.COLLAB_CLASSES ? parseInt(process.env.COLLAB_CLASSES, 10) : 0; + if (COURSE_ID && TOKEN && TARGET_URL) { + const socketConfiguration: SocketConfiguration = { + baseUrl: TARGET_URL, + path: '/board-collaboration', + token: TOKEN, + connectTimeout: 5000, + }; + + const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + const createBoardLoadTest: CreateBoardLoadTest = (...args) => new BoardLoadTest(...args); + const runner = new LoadtestRunner(socketConnectionManager, createBoardLoadTest); + + await runner.runLoadtest({ + socketConfiguration, + courseId: COURSE_ID, + configurations: [ + { classDefinition: viewersClass, amount: viewerClassesAmount }, + { classDefinition: collaborativeClass, amount: collabClassesAmount }, + ], + }); + } else { + expect('this should only be ran manually').toBeTruthy(); + } + }, 600000); +}); diff --git a/apps/server/src/modules/board/loadtest/board-load-test.spec.ts b/apps/server/src/modules/board/loadtest/board-load-test.spec.ts new file mode 100644 index 00000000000..e4ced34c294 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/board-load-test.spec.ts @@ -0,0 +1,128 @@ +import { BoardLoadTest } from './board-load-test'; +import { fastEditor } from './helper/class-definitions'; +import { SocketConnectionManager } from './socket-connection-manager'; +import { ClassDefinition } from './types'; +import { SocketConnection } from './socket-connection'; +import { LoadtestClient } from './loadtest-client'; + +jest.mock('./helper/sleep', () => { + return { sleep: () => Promise.resolve(true) }; +}); + +jest.mock('./loadtest-client', () => { + return { + createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), + createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), + createElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + }; +}); + +jest.mock('./socket-connection-manager'); + +const testClass: ClassDefinition = { + name: 'viewersClass', + users: [{ ...fastEditor, amount: 5 }], +}; + +beforeEach(() => { + jest.resetAllMocks(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('BoardLoadTest', () => { + const setup = () => { + const socketConfiguration = { baseUrl: '', path: '', token: '' }; + const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + const socketConnection = new SocketConnection(socketConfiguration, console.log); + + const boarLoadTest = new BoardLoadTest(socketConnectionManager, console.log); + return { boarLoadTest, socketConnectionManager, socketConnection }; + }; + + describe('runBoardTest', () => { + describe('if no userProfiles are provided', () => { + it('should do nothing', async () => { + const { boarLoadTest } = setup(); + const boardId = 'board-id'; + const configuration = { name: 'my-configuration', users: [], simulateUsersTimeMs: 2000 }; + + const response = await boarLoadTest.runBoardTest(boardId, configuration); + + expect(response).toBeUndefined(); + }); + }); + + describe('if userProfiles are provided', () => { + it('should create socketConnections for all users', async () => { + const { boarLoadTest, socketConnectionManager } = setup(); + const boardId = 'board-id'; + + await boarLoadTest.runBoardTest(boardId, testClass); + + expect(socketConnectionManager.createConnection).toHaveBeenCalledTimes(5); + }); + }); + }); + + describe('simulateUserActions', () => { + it('should create columns and cards', async () => { + const { boarLoadTest } = setup(); + const loadtestClient = { + createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), + createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), + createElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + fetchBoard: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + } as unknown as LoadtestClient; + const userProfile = fastEditor; + + await boarLoadTest.simulateUserActions(loadtestClient, userProfile, 50); + + expect(loadtestClient.createColumn).toHaveBeenCalled(); + expect(loadtestClient.createCard).toHaveBeenCalled(); + }, 10000); + }); + + describe('createColumn', () => { + it('should create a column', async () => { + const { boarLoadTest } = setup(); + + const loadtestClient = { + createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + } as unknown as LoadtestClient; + await boarLoadTest.createColumn(loadtestClient); + + expect(loadtestClient.createColumn).toHaveBeenCalled(); + }); + }); + + describe('createRandomCard', () => { + it('should create a card', async () => { + const { boarLoadTest } = setup(); + boarLoadTest.trackColumn('some-id'); + + const loadtestClient = { + createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + } as unknown as LoadtestClient; + await boarLoadTest.createRandomCard(loadtestClient); + + expect(loadtestClient.createCard).toHaveBeenCalled(); + }, 100000); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/board-load-test.ts b/apps/server/src/modules/board/loadtest/board-load-test.ts new file mode 100644 index 00000000000..94c4287d118 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/board-load-test.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-await-in-loop */ +import { duplicateUserProfiles } from './helper/class-definitions'; +import { getRandomCardTitle, getRandomLink, getRandomRichContentBody } from './helper/randomData'; +import { sleep } from './helper/sleep'; +import { LoadtestClient } from './loadtest-client'; +import { SocketConnectionManager } from './socket-connection-manager'; +import { Callback, ClassDefinition, UserProfile } from './types'; + +const SIMULATE_USER_TIME_MS = 60000; + +export class BoardLoadTest { + private columns: { id: string; cards: { id: string }[] }[] = []; + + constructor(private socketConnectionManager: SocketConnectionManager, private onError: Callback) {} + + async runBoardTest(boardId: string, configuration: ClassDefinition): Promise { + try { + const userProfiles = duplicateUserProfiles(configuration.users); + const userClients = await this.initializeLoadtestClients(userProfiles.length, boardId); + await this.simulateUsersActions(userClients, userProfiles); + } catch (err) { + this.onError((err as Error).message); + } + } + + async initializeLoadtestClients(amount: number, boardId: string): Promise { + const promises = Array(amount) + .fill(1) + .map(() => this.initializeLoadtestClient(boardId)); + const results = await Promise.all(promises); + return results; + } + + async initializeLoadtestClient(boardId: string): Promise { + const socketConnection = await this.socketConnectionManager.createConnection(); + const loadtestClient = new LoadtestClient(socketConnection, boardId); + + /* istanbul ignore next */ + await sleep(Math.ceil(Math.random() * 3000)); + /* istanbul ignore next */ + await loadtestClient.fetchBoard(); + /* istanbul ignore next */ + return loadtestClient; + } + + async simulateUsersActions(loadtestClients: LoadtestClient[], userProfiles: UserProfile[]) { + // eslint-disable-next-line arrow-body-style + const promises = loadtestClients.map((loadtestClient, index) => { + /* istanbul ignore next */ + return this.simulateUserActions(loadtestClient, userProfiles[index]); + }); + await Promise.all(promises); + } + + async simulateUserActions(loadtestClient: LoadtestClient, userProfile: UserProfile, actionsMax = 1000000) { + const startTime = performance.now(); + + await sleep(Math.ceil(Math.random() * 3000)); + + let actionCount = 0; + while (performance.now() - startTime < SIMULATE_USER_TIME_MS && actionCount < actionsMax) { + if (userProfile.isActive) { + try { + if (this.columnCount() === 0) { + await this.createColumn(loadtestClient); + } else if (this.columnCount() > 20) { + /* istanbul ignore next */ + await this.createRandomCard(loadtestClient, userProfile.sleepMs); + } else if (Math.random() > 0.8) { + await this.createColumn(loadtestClient); + } else { + await this.createRandomCard(loadtestClient, userProfile.sleepMs); + } + actionCount += 1; + } catch (err) { + /* istanbul ignore next */ + this.onError((err as Error).message); + } + } + await sleep(userProfile.sleepMs); + } + } + + async createColumn(loadtestClient: LoadtestClient) { + const column = await loadtestClient.createColumn(); + const position = this.trackColumn(column.id); + await loadtestClient.updateColumnTitle({ columnId: column.id, newTitle: `${position}. Spalte` }); + } + + async createRandomCard(loadtestClient: LoadtestClient, pauseMs = 1000) { + const columnId = this.getRandomColumnId(); + const card = await loadtestClient.createCard({ columnId }); + this.trackCard(columnId, card.id); + await sleep(pauseMs); + await loadtestClient.updateCardTitle({ cardId: card.id, newTitle: getRandomCardTitle() }); + await sleep(pauseMs); + for (let i = 0; i < Math.ceil(Math.random() * 5); i += 1) { + await this.createRandomElement(loadtestClient, card.id); + await sleep(pauseMs); + } + } + + async createRandomElement(loadtestClient: LoadtestClient, cardId: string) { + if (Math.random() > 0.5) { + await loadtestClient.createAndUpdateLinkElement(cardId, getRandomLink()); + } else { + await loadtestClient.createAndUpdateTextElement(cardId, getRandomRichContentBody()); + } + } + + trackColumn(columnId: string) { + this.columns.push({ id: columnId, cards: [] }); + return this.columns.length; + } + + trackCard(columnId: string, cardId: string) { + const column = this.columns.find((col) => col.id === columnId); + if (column) { + column.cards.push({ id: cardId }); + } else { + /* istanbul ignore next */ + throw new Error(`Column not found: ${columnId}`); + } + } + + columnCount() { + return this.columns.length; + } + + getRandomColumnId() { + return this.columns[Math.floor(Math.random() * this.columns.length)].id; + } +} diff --git a/apps/server/src/modules/board/loadtest/connection.load.spec.ts b/apps/server/src/modules/board/loadtest/connection.load.spec.ts new file mode 100644 index 00000000000..9118c8370d7 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/connection.load.spec.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-promise-executor-return */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-process-env */ +import { SocketConnectionManager } from './socket-connection-manager'; +import { SocketConfiguration } from './types'; + +const CONNECTION_AMOUNT = parseInt(process.env.CONNECTION_AMOUNT ?? '640', 10); + +describe('Board Collaboration - Connection Load Test', () => { + async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + it(`should run a connection load test: ${CONNECTION_AMOUNT} connections`, async () => { + const { COURSE_ID, TOKEN, TARGET_URL } = process.env; + if (COURSE_ID && TOKEN && TARGET_URL) { + const socketConfiguration: SocketConfiguration = { + baseUrl: TARGET_URL, + path: '/board-collaboration', + token: TOKEN, + connectTimeout: 50000, + }; + const manager = new SocketConnectionManager(socketConfiguration); + const sockets = await manager.createConnections(CONNECTION_AMOUNT); + await sleep(3000); + expect(sockets).toHaveLength(CONNECTION_AMOUNT); + await manager.destroySocketConnections(sockets); + } else { + expect('this should only be ran manually').toBeTruthy(); + } + }, 600000); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/class-definitions.spec.ts b/apps/server/src/modules/board/loadtest/helper/class-definitions.spec.ts new file mode 100644 index 00000000000..167fb535ada --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/class-definitions.spec.ts @@ -0,0 +1,99 @@ +import { viewersClass, collaborativeClass, duplicateUserProfiles, createSeveralClasses } from './class-definitions'; +import { UserProfileWithAmount, ClassDefinition, Configuration } from '../types'; + +describe('classDefintions', () => { + describe('viewersClass', () => { + it('should have the correct structure and values', () => { + expect(viewersClass).toEqual( + expect.objectContaining({ + name: 'viewersClass', + }) + ); + const viewers = viewersClass.users.find((user) => user.name === 'viewer'); + expect(viewers).toBeDefined(); + }); + }); + + describe('collaborativeClass', () => { + it('should have the correct structure and values', () => { + expect(collaborativeClass).toEqual( + expect.objectContaining({ + name: 'collaborativeClass', + }) + ); + const editorCount = collaborativeClass.users + .filter((user) => ['fastEditor', 'slowEditor'].includes(user.name)) + .map((user) => user.amount) + .reduce((sum, current) => sum + current, 0); + expect(editorCount).toBeGreaterThanOrEqual(30); + }); + }); + + describe('duplicateUserProfiles', () => { + it('should correctly duplicate user profiles based on the amount property', () => { + const users: UserProfileWithAmount[] = [ + { name: 'fastEditor', sleepMs: 1000, isActive: true, amount: 2 }, + { name: 'slowEditor', sleepMs: 3000, isActive: true, amount: 1 }, + { name: 'viewer', sleepMs: 1000, isActive: false, amount: 0 }, + ]; + + const result = duplicateUserProfiles(users); + expect(result).toEqual([ + { name: 'fastEditor', sleepMs: 1000, isActive: true }, + { name: 'fastEditor', sleepMs: 1000, isActive: true }, + { name: 'slowEditor', sleepMs: 3000, isActive: true }, + ]); + }); + + it('should return an empty array if all amounts are zero', () => { + const users: UserProfileWithAmount[] = [ + { name: 'fastEditor', sleepMs: 1000, isActive: true, amount: 0 }, + { name: 'slowEditor', sleepMs: 3000, isActive: true, amount: 0 }, + { name: 'viewer', sleepMs: 1000, isActive: true, amount: 0 }, + ]; + + const result = duplicateUserProfiles(users); + expect(result).toEqual([]); + }); + }); + + describe('createSeveralClasses', () => { + it('should correctly create multiple instances of a class definition', () => { + const classDefinition: ClassDefinition = { + name: 'testClass', + users: [ + { name: 'fastEditor', sleepMs: 1000, isActive: true, amount: 1 }, + { name: 'slowEditor', sleepMs: 3000, isActive: true, amount: 1 }, + ], + }; + + const configurations: Configuration[] = [ + { + classDefinition, + amount: 3, + }, + ]; + + const result = createSeveralClasses(configurations); + expect(result).toEqual([classDefinition, classDefinition, classDefinition]); + }); + + it('should return an empty array if the amount is zero', () => { + const configurations: Configuration[] = [ + { + classDefinition: { + name: 'testClass', + users: [ + { name: 'fastEditor', sleepMs: 1000, isActive: true, amount: 1 }, + { name: 'slowEditor', sleepMs: 3000, isActive: true, amount: 1 }, + ], + }, + amount: 0, + }, + ]; + + const result = createSeveralClasses(configurations); + expect(result).toEqual([]); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/class-definitions.ts b/apps/server/src/modules/board/loadtest/helper/class-definitions.ts new file mode 100644 index 00000000000..139b68440ae --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/class-definitions.ts @@ -0,0 +1,43 @@ +import { ClassDefinition, Configuration, UserProfile, UserProfileWithAmount } from '../types'; + +export const fastEditor: UserProfile = { name: 'fastEditor', sleepMs: 1000, isActive: true }; +export const slowEditor: UserProfile = { name: 'slowEditor', sleepMs: 3000, isActive: true }; +export const viewer: UserProfile = { name: 'viewer', sleepMs: 3000, isActive: false }; + +export const viewersClass: ClassDefinition = { + name: 'viewersClass', + users: [ + { ...fastEditor, amount: 1 }, + { ...slowEditor, amount: 0 }, + { ...viewer, amount: 30 }, + ], +}; + +export const collaborativeClass: ClassDefinition = { + name: 'collaborativeClass', + users: [ + { ...fastEditor, amount: 3 }, + { ...slowEditor, amount: 27 }, + { ...viewer, amount: 0 }, + ], +}; + +export const duplicateUserProfiles = (users: UserProfileWithAmount[]) => { + const expandedUsers: UserProfile[] = []; + users.forEach(({ amount, ...user }: UserProfileWithAmount) => { + if (amount > 0) { + const userProfiles = Array(amount).fill(user) as UserProfile[]; + expandedUsers.push(...userProfiles); + } + }); + return expandedUsers; +}; + +export const createSeveralClasses = (configurations: Configuration[]) => + configurations.reduce((all, configuration) => { + if (configuration.amount > 0) { + const additionalClasses = Array(configuration.amount).fill(configuration.classDefinition) as ClassDefinition[]; + all = [...all, ...additionalClasses]; + } + return all; + }, [] as ClassDefinition[]); diff --git a/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts b/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts new file mode 100644 index 00000000000..e6839a7f146 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts @@ -0,0 +1,56 @@ +import { createBoard } from './create-board'; + +describe('createBoards', () => { + describe('createBoard', () => { + const apiBaseUrl = 'http://example.com'; + const courseId = 'course123'; + const mockFetch = jest.fn(); + + beforeEach(() => { + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a board and return its id', async () => { + const boardId = 'board123'; + const token = 'test-token'; + mockFetch.mockResolvedValueOnce({ + status: 201, + json: () => { + return { id: boardId }; + }, + }); + + const result = await createBoard(apiBaseUrl, token, courseId); + + expect(result).toBe(boardId); + expect(mockFetch).toHaveBeenCalledWith( + `${apiBaseUrl}/api/v3/boards`, + expect.objectContaining({ + method: 'POST', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ); + }); + + it('should throw an error if the board creation fails', async () => { + const token = 'test-token'; + mockFetch.mockResolvedValueOnce({ + status: 400, + json: () => { + return {}; + }, + }); + + await expect(createBoard(apiBaseUrl, token, courseId)).rejects.toThrow( + 'Failed to create board: 400 - check token and target in env-variables' + ); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/create-board.ts b/apps/server/src/modules/board/loadtest/helper/create-board.ts new file mode 100644 index 00000000000..820ee038140 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/create-board.ts @@ -0,0 +1,29 @@ +import { BoardExternalReferenceType, BoardLayout } from '../../domain'; + +export const createBoard = async (apiBaseUrl: string, token: string, courseId: string) => { + const boardTitle = `${new Date().toISOString().substring(0, 10)} ${new Date().toLocaleTimeString( + 'de-DE' + )} - Lasttest`; + + const response = await fetch(`${apiBaseUrl}/api/v3/boards`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: boardTitle, + parentId: courseId, + parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.COLUMNS, + }), + }); + + if (response.status !== 201) { + throw new Error(`Failed to create board: ${response.status} - check token and target in env-variables`); + } + const body = (await response.json()) as unknown as { id: string }; + return body.id; +}; diff --git a/apps/server/src/modules/board/loadtest/helper/format-date.spec.ts b/apps/server/src/modules/board/loadtest/helper/format-date.spec.ts new file mode 100644 index 00000000000..f60165796ac --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/format-date.spec.ts @@ -0,0 +1,35 @@ +import { formatDate } from './format-date'; + +describe('formatDate', () => { + it('should format a standard date correctly', () => { + const date = new Date('2023-10-05T14:48:00.000Z'); + const formattedDate = formatDate(date); + + expect(formattedDate).toEqual(expect.stringContaining('2023-10-05')); + expect(formattedDate).toEqual(expect.stringContaining(':48:00')); + }); + + it('should format a date with a different time correctly', () => { + const date = new Date('2023-10-05T08:30:00.000Z'); + const formattedDate = formatDate(date); + + expect(formattedDate).toEqual(expect.stringContaining('2023-10-05')); + expect(formattedDate).toEqual(expect.stringContaining(':30:00')); + }); + + it('should handle midnight correctly', () => { + const date = new Date('2023-10-05T00:00:00.000Z'); + const formattedDate = formatDate(date); + + expect(formattedDate).toEqual(expect.stringContaining('2023-10-05')); + expect(formattedDate).toEqual(expect.stringContaining(':00:00')); + }); + + it('should format a date with different locale correctly', () => { + const date = new Date('2023-10-05T14:42:00.000Z'); + const formattedDate = formatDate(date); + + expect(formattedDate).toEqual(expect.stringContaining('2023-10-05')); + expect(formattedDate).toEqual(expect.stringContaining(':42:00')); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/format-date.ts b/apps/server/src/modules/board/loadtest/helper/format-date.ts new file mode 100644 index 00000000000..de33e5673d8 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/format-date.ts @@ -0,0 +1 @@ +export const formatDate = (date: Date) => `${date.toISOString().slice(0, 10)} ${date.toLocaleTimeString('de-DE')}`; diff --git a/apps/server/src/modules/board/loadtest/helper/get-url-configuration.spec.ts b/apps/server/src/modules/board/loadtest/helper/get-url-configuration.spec.ts new file mode 100644 index 00000000000..e9bf7e04dd9 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/get-url-configuration.spec.ts @@ -0,0 +1,23 @@ +import { getUrlConfiguration } from './get-url-configuration'; + +describe('getUrlConfiguration', () => { + it('should return the correct configuration for localhost', () => { + const urlConfiguration = getUrlConfiguration(); + + expect(urlConfiguration).toEqual({ + websocket: 'http://localhost:4450', + api: 'http://localhost:3030', + web: 'http://localhost:4000', + }); + }); + + it('should return the correct configuration for a custom target', () => { + const urlConfiguration = getUrlConfiguration('http://example.com'); + + expect(urlConfiguration).toEqual({ + websocket: 'http://example.com', + api: 'http://example.com', + web: 'http://example.com', + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/get-url-configuration.ts b/apps/server/src/modules/board/loadtest/helper/get-url-configuration.ts new file mode 100644 index 00000000000..b7cda817646 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/get-url-configuration.ts @@ -0,0 +1,16 @@ +import { UrlConfiguration } from '../types'; + +export const getUrlConfiguration = (target?: string): UrlConfiguration => { + if (target === undefined || /localhost/.test(target)) { + return { + websocket: 'http://localhost:4450', + api: 'http://localhost:3030', + web: 'http://localhost:4000', + }; + } + return { + websocket: target, + api: target, + web: target, + }; +}; diff --git a/apps/server/src/modules/board/loadtest/helper/randomData.spec.ts b/apps/server/src/modules/board/loadtest/helper/randomData.spec.ts new file mode 100644 index 00000000000..34b2dd7948e --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/randomData.spec.ts @@ -0,0 +1,33 @@ +import { getRandomLink, getRandomRichContentBody, getRandomCardTitle } from './randomData'; + +describe('randomData', () => { + describe('getRandomLink', () => { + it('should return a valid link', () => { + const randomLink = getRandomLink(); + + expect(typeof randomLink.title).toBe('string'); + expect(randomLink.title).toBeDefined(); + expect(randomLink.title?.length).toBeGreaterThan(3); + expect(randomLink.url).toBeDefined(); + expect(randomLink.url).toEqual(expect.stringContaining('http')); + }); + }); + + describe('getRandomRichContentBody', () => { + it('should return a valid text', () => { + const randomText = getRandomRichContentBody(); + + expect(randomText).toBeDefined(); + expect(randomText.length).toBeGreaterThan(20); + }); + }); + + describe('getRandomCardTitle', () => { + it('should return a valid title', () => { + const randomTitle = getRandomCardTitle(); + + expect(randomTitle).toBeDefined(); + expect(randomTitle.length).toBeGreaterThan(3); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/randomData.ts b/apps/server/src/modules/board/loadtest/helper/randomData.ts new file mode 100644 index 00000000000..13765ea6abf --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/randomData.ts @@ -0,0 +1,29 @@ +import { LinkContentBody } from '../../controller/dto'; + +export const getRandomLink = (): LinkContentBody => { + const links = [ + { url: 'https://www.google.com', title: 'Google' }, + { url: 'https://www.zdf.de', title: 'ZDF Mediathek' }, + { url: 'https://www.tagesschau.de', title: 'Tagesschau' }, + { url: 'https://www.sueddeutsche.de', title: 'Süddeutsche' }, + { url: 'https://www.zeit.de', title: 'Die Zeit' }, + { url: 'https://www.spiegel.de', title: 'Spiegel.de' }, + ]; + return links[Math.floor(Math.random() * links.length)]; +}; + +export const getRandomRichContentBody = (): string => { + const texts = [ + 'Es ist nicht wichtig, wie groß der erste Schritt ist, sondern in welche Richtung er geht.', + 'Niemand weiß, was er kann, bis er es probiert hat.', + 'Was Du mit guter Laune tust, fällt Dir nicht schwer.', + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Damit Ihr indess erkennt, woher dieser ganze Irrthum gekommen ist, und weshalb man die Lust anklagt und den Schmerz lobet, so will ich Euch Alles eröffnen und auseinander setzen, was jener Begründer der Wahrheit und gleichsam Baumeister des glücklichen Lebens selbst darüber gesagt hat.', + ]; + return texts[Math.floor(Math.random() * texts.length)]; +}; + +export const getRandomCardTitle = (): string => { + const titles = ['Sonstiges', 'Einleitung', 'Beispiele', 'Geschichte', 'Meeresbewohner']; + return titles[Math.floor(Math.random() * titles.length)]; +}; diff --git a/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.spec.ts b/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.spec.ts new file mode 100644 index 00000000000..4bb5df32416 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.spec.ts @@ -0,0 +1,58 @@ +import { useResponseTimes } from './responseTimes.composable'; + +describe('responseTimes.composable', () => { + const setup = () => { + const { addResponseTime, getResponseTimes, getTotalAvg, getAvgByAction, reset } = useResponseTimes(); + + reset(); + + return { addResponseTime, getResponseTimes, getTotalAvg, getAvgByAction }; + }; + + describe('addResponseTime', () => { + it('should store an added response time', () => { + const { addResponseTime, getResponseTimes } = setup(); + + addResponseTime({ action: 'action', responseTime: 100 }); + + expect(getResponseTimes()).toEqual([{ action: 'action', responseTime: 100 }]); + }); + + it('should store all added response times', () => { + const { addResponseTime, getResponseTimes } = setup(); + + addResponseTime({ action: 'create-card', responseTime: 60 }); + addResponseTime({ action: 'create-element', responseTime: 70 }); + addResponseTime({ action: 'update-card-title', responseTime: 80 }); + addResponseTime({ action: 'update-element', responseTime: 90 }); + + expect(getResponseTimes()).toHaveLength(4); + }); + }); + + describe('getTotalAvg', () => { + it('should return the average response time of all added response times', () => { + const { addResponseTime, getTotalAvg } = setup(); + + addResponseTime({ action: 'create-card', responseTime: 60 }); + addResponseTime({ action: 'create-element', responseTime: 70 }); + addResponseTime({ action: 'update-card-title', responseTime: 80 }); + addResponseTime({ action: 'update-element', responseTime: 90 }); + + expect(getTotalAvg()).toBe('75.00'); + }); + }); + + describe('getAvgByAction', () => { + it('should return the average response time of all added response times grouped by action', () => { + const { addResponseTime, getAvgByAction } = setup(); + + addResponseTime({ action: 'create-card', responseTime: 60 }); + addResponseTime({ action: 'create-card', responseTime: 70 }); + addResponseTime({ action: 'update-card-title', responseTime: 80 }); + addResponseTime({ action: 'update-card-title', responseTime: 90 }); + + expect(getAvgByAction()).toEqual({ 'create-card': '65.00', 'update-card-title': '85.00' }); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.ts b/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.ts new file mode 100644 index 00000000000..e03df6709f9 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/responseTimes.composable.ts @@ -0,0 +1,47 @@ +import { groupBy } from 'lodash'; +import { ResponseTimeRecord } from '../types'; + +let responseTimes: ResponseTimeRecord[] = []; + +export const useResponseTimes = () => { + function formatTime(time: number) { + return `${time.toFixed(2)}`; + } + + function addResponseTime(responseTime: ResponseTimeRecord) { + responseTimes.push(responseTime); + } + + function getResponseTimes() { + return responseTimes; + } + + function getTotalAvg() { + return formatTime(responseTimes.reduce((acc, curr) => acc + curr.responseTime, 0) / responseTimes.length); + } + + function getAvgByAction() { + const grouped = groupBy(responseTimes, 'action'); + const actions = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); + const avgByAction: Record = {}; + for (const action of actions) { + const records = grouped[action]; + const avg = records.reduce((all, cur) => all + cur.responseTime, 0) / records.length; + avgByAction[action] = formatTime(avg); + } + + return avgByAction; + } + + function reset() { + responseTimes = []; + } + + return { + addResponseTime, + getResponseTimes, + getAvgByAction, + getTotalAvg, + reset, + }; +}; diff --git a/apps/server/src/modules/board/loadtest/helper/sleep.ts b/apps/server/src/modules/board/loadtest/helper/sleep.ts new file mode 100644 index 00000000000..1291aed6947 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/helper/sleep.ts @@ -0,0 +1,4 @@ +/* eslint-disable no-promise-executor-return */ +export async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/server/src/modules/board/loadtest/loadtest-client.spec.ts b/apps/server/src/modules/board/loadtest/loadtest-client.spec.ts new file mode 100644 index 00000000000..f4055f60003 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/loadtest-client.spec.ts @@ -0,0 +1,257 @@ +// Rest of the code... +import { InputFormat } from '@shared/domain/types'; +import { ContentElementType } from '../domain'; +import { LoadtestClient } from './loadtest-client'; +import { SocketConnection } from './socket-connection'; +import { UpdateContentElementMessageParams } from '../gateway/dto'; + +jest.mock('./socket-connection'); + +describe('LoadtestClient', () => { + let loadtestClient: LoadtestClient; + let socketConnection: SocketConnection; + const boardId = 'board123'; + + beforeEach(() => { + socketConnection = new SocketConnection({ path: '', baseUrl: '', token: '' }, console.log); + + loadtestClient = new LoadtestClient(socketConnection, boardId); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchBoard', () => { + it('should fetch the board', async () => { + socketConnection.emitAndWait = jest.fn(); + + await loadtestClient.fetchBoard(); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('fetch-board', expect.objectContaining({ boardId })); + }); + }); + + describe('fetchCard', () => { + it('should fetch a card', async () => { + const cardId = 'my-card-id'; + const payload = { cardIds: [cardId] }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newCard: { id: cardId } }); + + await loadtestClient.fetchCard(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('fetch-card', payload); + }); + }); + + describe('createColumn', () => { + it('should create a column', async () => { + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newColumn: { id: 'column123' } }); + + await loadtestClient.createColumn(); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('create-column', { boardId }); + }); + }); + + describe('createCard', () => { + it('should create a card', async () => { + const payload = { title: 'New Card', columnId: 'column123' }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newCard: { id: 'card123' } }); + + await loadtestClient.createCard(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('create-card', payload); + }); + }); + + describe('deleteColumn', () => { + it('should delete a column', async () => { + const columnId = 'column123'; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ columnId }); + + await loadtestClient.deleteColumn({ columnId }); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('delete-column', { columnId }); + }); + }); + + describe('deleteCard', () => { + it('should delete a card', async () => { + const payload = { cardId: 'card123' }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce(payload); + + await loadtestClient.deleteCard(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('delete-card', payload); + }); + }); + + describe('deleteElement', () => { + it('should delete an element', async () => { + const payload = { cardId: 'my-card-id', elementId: 'element123' }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce(payload); + + await loadtestClient.deleteElement(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('delete-element', payload); + }); + }); + + describe('createElement', () => { + it('should create an element', async () => { + const cardId = 'my-card-id'; + const payload = { cardId, type: ContentElementType.RICH_TEXT, content: 'Hello World' }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newElement: { id: 'element123' } }); + + await loadtestClient.createElement(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('create-element', payload); + }); + }); + + describe('updateBoardTitle', () => { + it('should update the board title', async () => { + const payload = { + boardId: 'board123', + newTitle: 'New Board Title', + }; + socketConnection.emitAndWait = jest.fn(); + + await loadtestClient.updateBoardTitle(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('update-board-title', payload); + }); + }); + + describe('updateColumnTitle', () => { + it('should update the column title', async () => { + const payload = { + columnId: 'column123', + newTitle: 'New Column Title', + }; + socketConnection.emitAndWait = jest.fn(); + + await loadtestClient.updateColumnTitle(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('update-column-title', payload); + }); + }); + + describe('updateCardTitle', () => { + it('should update the card title', async () => { + const payload = { cardId: 'card123', newTitle: 'New Card Title' }; + socketConnection.emitAndWait = jest.fn(); + + await loadtestClient.updateCardTitle(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('update-card-title', payload); + }); + }); + + describe('updateElement', () => { + it('should update an element', async () => { + const payload = { + elementId: 'my-element-id', + data: { + type: ContentElementType.RICH_TEXT, + content: { + inputFormat: InputFormat.RICH_TEXT_CK5, + text: `

Some text...

`, + }, + }, + } as UpdateContentElementMessageParams; + socketConnection.emitAndWait = jest.fn(); + + await loadtestClient.updateElement(payload); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('update-element', payload); + }); + }); + + describe('createAndUpdateLinkElement', () => { + it('should create and update a link element', async () => { + const cardId = 'card123'; + const content = { url: 'https://example.com', title: 'Example' }; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newElement: { elementId: 'element123' } }); + + await loadtestClient.createAndUpdateLinkElement(cardId, content); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith( + 'create-element', + expect.objectContaining({ cardId, type: 'link' }) + ); + expect(socketConnection.emitAndWait).toHaveBeenCalledWith('update-element', expect.any(Object)); + }); + }); + + describe('createAndUpdateTextElement', () => { + describe('when text is shorter than 20 characters', () => { + it('should create and update a text element', async () => { + const cardId = 'card123'; + const text = 'Lorem ipsum'; + const simulateTyping = true; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newElement: { elementId: 'element123' } }); + + await loadtestClient.createAndUpdateTextElement(cardId, text, simulateTyping); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith( + 'create-element', + expect.objectContaining({ + cardId, + type: ContentElementType.RICH_TEXT, + }) + ); + expect(socketConnection.emitAndWait).toHaveBeenCalledTimes(2); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(1, 'create-element', expect.any(Object)); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(2, 'update-element', expect.any(Object)); + }); + }); + + describe('when text is longer than 20 characters', () => { + it('should send multiple updates', async () => { + const cardId = 'card123'; + const text = 'Lorem ipsum dolor sit amet consectetur'; + const simulateTyping = true; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newElement: { elementId: 'element123' } }); + + await loadtestClient.createAndUpdateTextElement(cardId, text, simulateTyping); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith( + 'create-element', + expect.objectContaining({ + cardId, + type: ContentElementType.RICH_TEXT, + }) + ); + expect(socketConnection.emitAndWait).toHaveBeenCalledTimes(3); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(1, 'create-element', expect.any(Object)); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(2, 'update-element', expect.any(Object)); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(3, 'update-element', expect.any(Object)); + }); + }); + + describe('when slicing is deactivated', () => { + it('should send one update', async () => { + const cardId = 'card123'; + const text = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt.'; + const simulateTyping = false; + socketConnection.emitAndWait = jest.fn().mockResolvedValueOnce({ newElement: { elementId: 'element123' } }); + + await loadtestClient.createAndUpdateTextElement(cardId, text, simulateTyping); + + expect(socketConnection.emitAndWait).toHaveBeenCalledWith( + 'create-element', + expect.objectContaining({ + cardId, + type: ContentElementType.RICH_TEXT, + }) + ); + + expect(socketConnection.emitAndWait).toHaveBeenCalledTimes(2); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(1, 'create-element', expect.any(Object)); + expect(socketConnection.emitAndWait).toHaveBeenNthCalledWith(2, 'update-element', expect.any(Object)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/loadtest-client.ts b/apps/server/src/modules/board/loadtest/loadtest-client.ts new file mode 100644 index 00000000000..650619155b0 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/loadtest-client.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-await-in-loop */ +import { chunk } from 'lodash'; +import { InputFormat } from '@shared/domain/types'; +import { + AnyContentElementResponse, + BoardResponse, + CardResponse, + ColumnResponse, + LinkContentBody, +} from '../controller/dto'; +import { ContentElementType } from '../domain'; +import { + CreateCardMessageParams, + CreateContentElementMessageParams, + DeleteCardMessageParams, + DeleteColumnMessageParams, + DeleteContentElementMessageParams, + FetchCardsMessageParams, + UpdateBoardTitleMessageParams, + UpdateCardTitleMessageParams, + UpdateColumnTitleMessageParams, + UpdateContentElementMessageParams, +} from '../gateway/dto'; +import { SocketConnection } from './socket-connection'; +import { sleep } from './helper/sleep'; + +export class LoadtestClient { + constructor(private socket: SocketConnection, private boardId: string) {} + + async fetchBoard() { + const result = (await this.socket.emitAndWait('fetch-board', { boardId: this.boardId })) as BoardResponse; + return result; + } + + async fetchCard(payload: FetchCardsMessageParams) { + const { newCard } = (await this.socket.emitAndWait('fetch-card', payload)) as { newCard: CardResponse }; + return newCard; + } + + async createColumn() { + const { newColumn } = (await this.socket.emitAndWait('create-column', { boardId: this.boardId })) as { + newColumn: ColumnResponse; + }; + return newColumn; + } + + async createCard(payload: CreateCardMessageParams) { + const { newCard } = (await this.socket.emitAndWait('create-card', payload)) as { newCard: CardResponse }; + return newCard; + } + + async deleteColumn(payload: DeleteColumnMessageParams) { + const result = (await this.socket.emitAndWait('delete-column', payload)) as { columnId: string }; + return result; + } + + async deleteCard(payload: DeleteCardMessageParams) { + const result = (await this.socket.emitAndWait('delete-card', payload)) as { cardId: string }; + return result; + } + + async deleteElement(payload: DeleteContentElementMessageParams) { + const result = (await this.socket.emitAndWait('delete-element', payload)) as { elementId: string }; + return result; + } + + async createElement(payload: CreateContentElementMessageParams) { + const { newElement } = (await this.socket.emitAndWait('create-element', payload)) as { + newElement: AnyContentElementResponse; + }; + return newElement; + } + + async updateBoardTitle(payload: UpdateBoardTitleMessageParams) { + const result = await this.socket.emitAndWait('update-board-title', payload); + return result; + } + + async updateColumnTitle(payload: UpdateColumnTitleMessageParams) { + const result = await this.socket.emitAndWait('update-column-title', payload); + return result; + } + + async updateCardTitle(payload: UpdateCardTitleMessageParams) { + const result = await this.socket.emitAndWait('update-card-title', payload); + return result; + } + + async updateElement(payload: UpdateContentElementMessageParams) { + const result = await this.socket.emitAndWait('update-element', payload); + return result; + } + + async createAndUpdateLinkElement(cardId: string, content: LinkContentBody) { + const element = await this.createElement({ + cardId, + type: ContentElementType.LINK, + }); + const result = await this.updateElement({ + elementId: element.id, + data: { type: ContentElementType.LINK, content }, + }); + return result; + } + + async createAndUpdateTextElement(cardId: string, text: string, simulateTyping = true) { + const element = await this.createElement({ + cardId, + type: ContentElementType.RICH_TEXT, + }); + + let textChunks: string[] = []; + if (simulateTyping === true) { + textChunks = chunk(text.split(''), 20).map((c) => c.join('')); + } else { + textChunks = [text]; + } + + let result; + let currentText = ''; + for (const textChunk of textChunks) { + currentText += textChunk; + result = await this.updateElement({ + elementId: element.id, + data: { + type: ContentElementType.RICH_TEXT, + content: { + inputFormat: InputFormat.RICH_TEXT_CK5, + text: `

${currentText}

`, + }, + }, + }); + + await sleep(500); + } + return result as UpdateContentElementMessageParams; + } +} diff --git a/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts b/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts new file mode 100644 index 00000000000..d0be412ccba --- /dev/null +++ b/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts @@ -0,0 +1,55 @@ +import { viewersClass } from './helper/class-definitions'; +import { LoadtestRunner } from './loadtest-runner'; +import { SocketConnectionManager } from './socket-connection-manager'; +import { Configuration } from './types'; + +jest.mock('./socket-connection-manager'); + +jest.mock('./helper/create-board', () => { + return { + createBoard: jest.fn().mockResolvedValue({ id: 'board123' }), + }; +}); + +describe('LoadtestRunner', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const runBoardTest = jest.fn().mockResolvedValue({ responseTimes: [] }); + const createBoardLoadTest = jest.fn().mockImplementation(() => { + return { runBoardTest }; + }); + const socketConfiguration = { baseUrl: 'http://localhost', path: '', token: '' }; + const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + const loadtestRunner = new LoadtestRunner(socketConnectionManager, createBoardLoadTest); + + return { loadtestRunner, socketConfiguration, runBoardTest }; + }; + + describe('getErrorCount', () => { + it('should get the error count correctly', () => { + const { loadtestRunner } = setup(); + loadtestRunner.onError('Error 1'); + loadtestRunner.onError('Error 2'); + loadtestRunner.onError('Error 3'); + + const errorCount = loadtestRunner.getErrorCount(); + + expect(errorCount).toBe(3); + }); + }); + + describe('runLoadtest', () => { + it('should run the loadtest', async () => { + const { loadtestRunner, socketConfiguration, runBoardTest } = setup(); + const courseId = '123'; + const configurations: Configuration[] = [{ classDefinition: viewersClass, amount: 1 }]; + + await loadtestRunner.runLoadtest({ socketConfiguration, courseId, configurations }); + + expect(runBoardTest).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/loadtest-runner.ts b/apps/server/src/modules/board/loadtest/loadtest-runner.ts new file mode 100644 index 00000000000..db88ee97656 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/loadtest-runner.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-await-in-loop */ +import { writeFileSync } from 'fs'; +import { Injectable } from '@nestjs/common'; +import { createSeveralClasses } from './helper/class-definitions'; +import { createBoard } from './helper/create-board'; +import { formatDate } from './helper/format-date'; +import { getUrlConfiguration } from './helper/get-url-configuration'; +import { useResponseTimes } from './helper/responseTimes.composable'; +import { SocketConnectionManager } from './socket-connection-manager'; +import { Callback, ClassDefinitionWithAmount, CreateBoardLoadTest, SocketConfiguration } from './types'; + +const { getAvgByAction, getTotalAvg } = useResponseTimes(); + +@Injectable() +export class LoadtestRunner { + private socketConnectionManager: SocketConnectionManager; + + private intervalHandle: NodeJS.Timeout | undefined; + + private startTime: number; + + private startDate: Date; + + private errors: string[] = []; + + private readonly createBoardLoadTest: CreateBoardLoadTest; + + constructor(socketConnectionManager: SocketConnectionManager, createBoardLoadTest: CreateBoardLoadTest) { + this.socketConnectionManager = socketConnectionManager; + this.createBoardLoadTest = createBoardLoadTest; + this.startTime = performance.now(); + this.startDate = new Date(); + } + + showStats() { + const seconds = Math.ceil((performance.now() - this.startTime) / 1000); + const clients = this.socketConnectionManager.getClientCount(); + const errors = this.getErrorCount(); + + // check how much the event loop is blocked + const time = process.hrtime(); + process.nextTick(() => { + /* istanbul ignore next */ + const diff = process.hrtime(time); + /* istanbul ignore next */ + const ms = diff[0] * 1e9 + diff[1] / 1000000; + /* istanbul ignore next */ + const eventloopBlockMs = ms.toFixed(2); + + // output the stats (after determining the event loop block time) + /* istanbul ignore next */ + process.stdout.write( + `${seconds}s - ${clients} clients connected - ${errors} errors | blocking: ${eventloopBlockMs}ms` + ); + }); + } + + startRegularStats = () => { + this.intervalHandle = setInterval(() => this.showStats(), 10000); + }; + + stopRegularStats = () => { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + } + this.showStats(); + }; + + onError: Callback = (message: unknown) => { + this.errors.push(message as string); + }; + + private createProtocol( + courseId: string, + socketConfiguration: SocketConfiguration, + configurations: ClassDefinitionWithAmount[] + ) { + const protocolFilename = `${formatDate(this.startDate)}_${Math.ceil(Math.random() * 1000)}.json`; + const protocol = { + protocolFilename, + startDateTime: formatDate(this.startDate), + endDateTime: formatDate(new Date()), + courseId, + socketConfiguration, + configurations, + responseTimes: { + ...getAvgByAction(), + totalAvg: getTotalAvg(), + }, + errorCount: this.errors.length, + errors: this.errors, + }; + writeFileSync(protocolFilename, JSON.stringify(protocol, null, 2)); + process.stdout.write(JSON.stringify(protocol, null, 2)); + return protocol; + } + + getErrorCount = () => this.errors.length; + + async runLoadtest({ + socketConfiguration, + courseId, + configurations, + }: { + socketConfiguration: SocketConfiguration; + courseId: string; + configurations: ClassDefinitionWithAmount[]; + }) { + const urls = getUrlConfiguration(socketConfiguration.baseUrl); + const classes = createSeveralClasses(configurations); + + this.startRegularStats(); + + const promises: Promise[] = classes.flatMap(async (classDefinition) => { + const boardLoadTest = this.createBoardLoadTest(this.socketConnectionManager, this.onError); + const boardId = await createBoard(urls.api, socketConfiguration.token, courseId); + return boardLoadTest.runBoardTest(boardId, classDefinition); + }); + + await Promise.all(promises); + + this.stopRegularStats(); + + this.createProtocol(courseId, socketConfiguration, configurations); + } +} diff --git a/apps/server/src/modules/board/loadtest/readme.md b/apps/server/src/modules/board/loadtest/readme.md index 24cbd18605c..e7cfbd89bc4 100644 --- a/apps/server/src/modules/board/loadtest/readme.md +++ b/apps/server/src/modules/board/loadtest/readme.md @@ -1,76 +1,53 @@ # Loadtesting the boards -The socket.io documentation suggests to use the tool artillery in order to load test a socket-io tool like our board-collaboration service. +The tests can be run from your local environment or from any other place that has the code and installed dependencies. -For defining scenarios you need to use/create Yaml-files that define which operations with which parameters need to be executed in which order. +## provide environment variables -Some sceneraios were already prepared and are stored in the subfolder scenarios. +In order to run the load tests you need to provide three environment variables: -## install artillery +### target -To run artillery from your local environment you need to install it first including an adapter that supports socketio-v3-websocket communication: +The Url of the server. -```sh -npm install -g artillery artillery-engine-socketio-v3 -``` +e.g. `export TARGET_URL=http://localhost:4450`
+e.g. `export TARGET_URL=https://bc-7830-board-loadtests-merge.brb.dbildungscloud.dev` -## manual execution +### courseId -To execute a scenario you can run artillery from the shell / commandline...: +The id of the course that the user (see next variable "token") is allowed to create boards in.
+e.g. `export COURSE_ID=66c493f577499cc64bf9aab4` -Using the `--variables` parameter it is possible to define several variables and there values that can be used in the scenerio-yaml-file: +### token -- **target**: defines the base url for all requests (REST and WebSocket) - e.g. `https://main.dbc.dbildungscloud.dev` -- **token**: a valid JWT for the targeted system -- **board_id**: id of an existing board the tests should be executed on +A valid JWT-token of a user that is allowed to create boards in the given course.
+e.g. `export TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6...` -```bash -npx artillery run --variables "{'target': 'https://main.dbc.dbildungscloud.dev', 'token': 'eJ....', 'board_id': '668d0e03bf3689d12e1e86fb' }" './scenarios/3users.yml' --output artilleryreport.json -``` +## run connection test -On Windows Powershell, the variables value needs to be wrapped in singlequotes, and inside the json you need to use backslash-escaped doublequotes: +This test is only trying to establish the defined amount of connections. It is useful to find out about any problems with larger amounts of connections. -```powershell -npx artillery run --variables '{\"target\": \"https://main.dbc.dbildungscloud.dev\", \"token\": \"eJ....\", \"board_id\": \"668d0e03bf3689d12e1e86fb\" }' './scenarios/3users.yml' --output artilleryreport.json -``` +To run the test: -## visualizing the recorded results - -It is possible to generate a HTML-report based on the recorded data. - -```powershell -npx artillery report --output=$board_title.html artilleryreport.json +```bash +npx jest apps/server/src/modules/board/loadtest/connection.load.spec.ts ``` -## automatic execution +## run board-collaboration test -You can run one of the existing scenarios by executing: +This test emulates multiple class settings (defined here: class-definitions.ts). +By default there are 20 viewer classes emulated (consisting of one fastEditor (teacher) and thirty viewers (students)). +There is also the definition of a collaboration class (constisting of 30 fastEditors). By default no collaboration-class is emulated. +The editors create columns, cards, texts, links etc. For each class we have a separate board that is automatically created at the beginning of the testrun. +At the end of the test you will see a summary in the bash - which is also written into a json file. This is helpful when comparing execution times (visible in grafana) with response times and the actual setting that was run. -```bash -bash runScenario.sh -``` +**Hint 1**: the amount of classes can be overruled via two environment variables: +VIEWER_CLASSES and COLLAB_CLASSES). -This will: +**Hint 2**: to modifiy what a viewerClass is - you can also manipulate the settings in class-definition.ts. -1. let you choose from scenario-files -2. create a fresh JWT-webtoken -3. create a fresh board (in one of the courses) the user has access to -4. name the board by a combination of datetime and the scenario name. -5. output a link to the generated board (in order open and see the test live) -6. start the execution of the scenario against this newly created board -7. generate a html report in the end - -You can also provide the target as the first and the name of the scenario as the second parameter - to avoid the need to select those. Here is an example: +To run the test: ```bash -bash runScenario.sh https://bc-6854-basic-load-tests.nbc.dbildungscloud.dev 3users +npx jest apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts ``` - -## password - -By typeing `export CARL_CORD_PASSWORD=realpassword` the script will not ask you anymore for the password to create a token. - -## Todos - -- [ ] enable optional parameter course_id diff --git a/apps/server/src/modules/board/loadtest/runScenario.sh b/apps/server/src/modules/board/loadtest/runScenario.sh deleted file mode 100644 index 0187f1c579f..00000000000 --- a/apps/server/src/modules/board/loadtest/runScenario.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/bin/bash - -function select_target() { - declare -a targets=("https://main.nbc.dbildungscloud.dev" "https://bc-6854-basic-load-tests.nbc.dbildungscloud.dev") - echo "Please select the target for the test:" >&2 - select target in "${targets[@]}"; do - if [[ -n $target ]]; then - break - else - echo "Invalid selection. Please try again." >&2 - fi - done -} - -function select_scenario() { - # list files in the scenarios directory - scenarios_dir="./scenarios" - declare -a scenario_files=($(ls $scenarios_dir)) - - echo "Please select a scenario file for the test:" >&2 - select scenario_file in "${scenario_files[@]}"; do - if [[ -n $scenario_file ]]; then - echo "You have selected: $scenario_file" >&2 - break - else - echo "Invalid selection. Please try again." >&2 - fi - done - - scenario_name="${scenario_file%.*}" -} - -function get_credentials() { - if [ -z "$CARL_CORD_PASSWORD" ]; then - echo "Password for Carl Cord is unknown. Provide it as an enviroment variable (CARL_CORD_PASSWORD) or enter it:" - read CARL_CORD_PASSWORD - export CARL_CORD_PASSWORD - fi -} - -function get_token() { - response=$(curl -s -f -X 'POST' \ - "$target/api/v3/authentication/local" \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d "{ - \"username\": \"lehrer@schul-cloud.org\", - \"password\": \"$CARL_CORD_PASSWORD\" - }") - - if [ $? -ne 0 ]; then - echo "ERROR: Failed to get token. Please check your credentials and target URL." >&2 - exit 1 - fi - - token=$(echo $response | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p') -} - -function get_course_id() { - response=$(curl -s -f -X 'GET' \ - "$target/api/v3/courses" \ - -H "Accept: application/json" \ - -H "Authorization: Bearer $token") - - if [ $? -ne 0 ]; then - echo "ERROR: Failed to get course list. Please check your credentials and target URL." >&2 - exit 1 - fi - - course_id=$(echo $response | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') -} - -function create_board_title() { - current_date=$(date +%Y-%m-%d_%H:%M) - board_title="${current_date}_$1" -} - -function create_board() { - response=$(curl -s -f -X 'POST' \ - "$target/api/v3/boards" \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $token" \ - -d "{ - \"title\": \"$board_title\", - \"parentId\": \"$course_id\", - \"parentType\": \"course\", - \"layout\": \"columns\" - }") - - if [ $? -ne 0 ]; then - echo "ERROR: Failed to create a board." >&2 - exit 1 - fi - - board_id=$(echo $response | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' ) -} - -if [ -z "$1" ]; then - select_target -else - target=$1 -fi -echo " " -echo "target: $target" - - -if [ -z "$2" ]; then - select_scenario - echo "scenario_name: $scenario_name" -else - scenario_name="$2" - scenario_name=${scenario_name//.yml/} -fi -echo "scenario_name: $scenario_name" - -get_credentials - -get_token -echo "token: ${token:0:50}..." -echo " " - -get_course_id -echo "course_id: $course_id" -echo " " - -create_board_title $scenario_name -echo "board_title: $board_title" - -create_board -echo "board_id $board_id" - -echo "board: $target/rooms/$board_id/board" -echo " " -echo "Running artillery test..." - -npx artillery run --variables "{\"target\": \"$target\", \"token\": \"$token\", \"board_id\": \"$board_id\" }" "./scenarios/$scenario_name.yml" --output artilleryreport.json - -npx artillery report --output=$board_title.html artilleryreport.json diff --git a/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml b/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml deleted file mode 100644 index 567cbaf703a..00000000000 --- a/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml +++ /dev/null @@ -1,75 +0,0 @@ -config: - target: '{{ target }}' - engines: - socketio: - transport: ['websocket', 'polling'] - path: '/board-collaboration' - socketio-v3: - path: '/board-collaboration' - timeout: 1000000 - extraHeaders: - Cookie: 'jwt={{ token }}' - - phases: - - duration: 300 - arrivalRate: 10 - maxVusers: 30 - -scenarios: - - name: create card - engine: socketio-v3 - socketio-v3: - extraHeaders: - Cookie: 'jwt={{ token }}' - flow: - - think: 1 - - - emit: - channel: 'fetch-board-request' - data: - boardId: '{{ board_id }}' - - - think: 1 - - - emit: - channel: 'create-column-request' - data: - boardId: '{{ board_id }}' - response: - on: 'create-column-success' - capture: - - json: $.newColumn.id - as: columnId - - - think: 1 - - - loop: - - emit: - channel: 'create-card-request' - data: - columnId: '{{ columnId}}' - response: - on: 'create-card-success' - capture: - - json: $.newCard.id - as: cardId - - - think: 1 - - - emit: - channel: 'fetch-card-request' - data: - cardIds: - - '{{ cardId }}' - - - think: 2 - - - emit: - channel: 'update-card-title-request' - data: - cardId: '{{ cardId }}' - newTitle: 'Card {{ cardId}}' - - - think: 1 - - count: 20 diff --git a/apps/server/src/modules/board/loadtest/scenarios/3users.yml b/apps/server/src/modules/board/loadtest/scenarios/3users.yml deleted file mode 100644 index 4fbeef037c8..00000000000 --- a/apps/server/src/modules/board/loadtest/scenarios/3users.yml +++ /dev/null @@ -1,57 +0,0 @@ -config: - target: '{{ target }}' - engines: - socketio: - transport: ['websocket', 'polling'] - path: '/board-collaboration' - socketio-v3: - path: '/board-collaboration' - timeout: 1000000 - extraHeaders: - Cookie: 'jwt={{ token }}' - - phases: - - duration: 1 - arrivalRate: 3 - -scenarios: - - name: create card - engine: socketio-v3 - socketio-v3: - extraHeaders: - Cookie: 'jwt={{ token }}' - flow: - - log: '{{ target }}' - - think: 1 - - - emit: - channel: 'create-column-request' - data: - boardId: '{{ board_id }}' - response: - on: 'create-column-success' - capture: - - json: $.newColumn.id - as: columnId - - - think: 1 - - - emit: - channel: 'create-card-request' - data: - columnId: '{{ columnId}}' - response: - on: 'create-card-success' - capture: - - json: $.newCard.id - as: cardId - - - think: 1 - - - emit: - channel: 'update-card-title-request' - data: - cardId: '{{ cardId }}' - newTitle: 'One {{ cardId}}' - - - think: 2 diff --git a/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml b/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml deleted file mode 100644 index ad7e993f829..00000000000 --- a/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml +++ /dev/null @@ -1,70 +0,0 @@ -config: - target: '{{ target }}' - engines: - socketio: - transport: ['websocket', 'polling'] - path: '/board-collaboration' - socketio-v3: - path: '/board-collaboration' - timeout: 1000000 - extraHeaders: - Cookie: 'jwt={{ token }}' - - phases: - - duration: 2 - arrivalRate: 50 - -scenarios: - - name: create card - engine: socketio-v3 - socketio: - extraHeaders: - Cookie: 'jwt={{ token }}' - socketio-v3: - extraHeaders: - Cookie: 'jwt={{ token }}' - flow: - - think: 1 - - - emit: - channel: 'create-column-request' - data: - boardId: '{{ board_id }}' - response: - on: 'create-column-success' - capture: - - json: $.newColumn.id - as: columnId - - - think: 2 - - - loop: - - emit: - channel: 'create-card-request' - data: - columnId: '{{ columnId}}' - response: - on: 'create-card-success' - capture: - - json: $.newCard.id - as: cardId - - - think: 1 - - - emit: - channel: 'fetch-card-request' - data: - cardIds: - - '{{ cardId }}' - - - think: 2 - - - emit: - channel: 'update-card-title-request' - data: - cardId: '{{ cardId }}' - newTitle: 'Card {{ cardId}}' - - - think: 1 - - count: 6 diff --git a/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts b/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts new file mode 100644 index 00000000000..bbf25c09b77 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts @@ -0,0 +1,32 @@ +import { SocketConnectionManager } from './socket-connection-manager'; + +jest.mock('./socket-connection'); + +describe('SocketConnectionManager', () => { + const setup = () => { + const socketConfiguration = { baseUrl: '', path: '', token: '' }; + const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + return { socketConnectionManager }; + }; + + describe('createConnections', () => { + it('should create the correct amount of connections', async () => { + const { socketConnectionManager } = setup(); + + await socketConnectionManager.createConnections(5); + + expect(socketConnectionManager.getClientCount()).toBe(5); + }); + }); + + describe('destroySocketConnections', () => { + it('should destroy the connections', async () => { + const { socketConnectionManager } = setup(); + const connections = await socketConnectionManager.createConnections(5); + + await socketConnectionManager.destroySocketConnections(connections); + + expect(socketConnectionManager.getClientCount()).toBe(0); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/socket-connection-manager.ts b/apps/server/src/modules/board/loadtest/socket-connection-manager.ts new file mode 100644 index 00000000000..dd8eb05221d --- /dev/null +++ b/apps/server/src/modules/board/loadtest/socket-connection-manager.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-await-in-loop */ +import { sleep } from './helper/sleep'; +import { SocketConnection } from './socket-connection'; +import { Callback, SocketConfiguration } from './types'; + +export class SocketConnectionManager { + private connections: SocketConnection[]; + + private socketConfiguration: SocketConfiguration; + + private onErrorHandler: Callback = console.log; + + constructor(socketConfiguration: SocketConfiguration) { + this.connections = []; + this.socketConfiguration = socketConfiguration; + } + + async createConnection(): Promise { + // eslint-disable-next-line arrow-body-style + const socket = new SocketConnection(this.socketConfiguration, (errorMessage: unknown) => { + /* istanbul ignore next */ + return this.onErrorHandler(errorMessage); + }); + await socket.connect(); + + this.connections.push(socket); + return socket; + } + + async createConnections(amount: number): Promise { + const connections: SocketConnection[] = []; + + while (connections.length < amount) { + const batchAmount = Math.min(100, amount - connections.length); + const promises = Array(batchAmount) + .fill(1) + .map(() => this.createConnection()); + + const allSettled = await Promise.allSettled(promises); + allSettled.forEach((res) => { + if (res.status === 'fulfilled') { + connections.push(res.value); + } else { + /* istanbul ignore next */ + this.onErrorHandler('failed to create connection'); + } + }); + await sleep(1000); + } + return connections; + } + + getClientCount() { + return this.connections.length; + } + + setOnErrorHandler(onErrorHandler: Callback) { + /* istanbul ignore next */ + this.onErrorHandler = onErrorHandler; + } + + async destroySocketConnections(sockets: SocketConnection[]) { + const promises = sockets.map((socket) => socket.close()); + await Promise.all(promises); + this.connections = this.connections.filter((connection) => !sockets.includes(connection)); + } +} diff --git a/apps/server/src/modules/board/loadtest/socket-connection.spec.ts b/apps/server/src/modules/board/loadtest/socket-connection.spec.ts new file mode 100644 index 00000000000..5b6f7f9d272 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/socket-connection.spec.ts @@ -0,0 +1,142 @@ +import { SocketConnection } from './socket-connection'; +import { Callback } from './types'; + +const onListeners: Record = {}; +const onMock = jest.fn().mockImplementation((action: string, listener: Callback) => { + onListeners[action] = onListeners[action] || []; + onListeners[action].push(listener); +}); + +const onceListeners: Record = {}; +const onceMock = (action: string, listener: Callback) => { + onceListeners[action] = onceListeners[action] || []; + onceListeners[action].push(listener); +}; + +const onAnyListeners: Callback[] = []; +const onAnyMock = (listener: Callback) => { + onAnyListeners.push(listener); +}; + +let doesConnectWork = true; + +const ioMock = { + emit: jest.fn(), + on: onMock, + onAny: onAnyMock, + once: onceMock, + connect: jest.fn().mockImplementation(() => { + if (doesConnectWork && onListeners.connect) { + onListeners.connect.forEach((listener) => listener(true)); + } + if (!doesConnectWork && onListeners.connect_error) { + onListeners.connect.forEach((listener) => listener(false)); + } + }), + close: jest.fn(), +}; + +jest.mock('socket.io-client', () => { + return { + io: jest.fn().mockImplementation(() => ioMock), + }; +}); + +describe('SocketConnection', () => { + const setup = () => { + const onError = jest.fn(); + const socketConfiguration = { baseUrl: 'http://localhost:4650', path: '/board-collaboration', token: 'abc' }; + const socketConnection = new SocketConnection(socketConfiguration, onError); + + return { socketConnection, onError }; + }; + + describe('connect', () => { + it('should resolve if the socket connects', async () => { + const { socketConnection } = setup(); + doesConnectWork = true; + const result = await socketConnection.connect(); + + expect(result).toBe(true); + }); + }); + + describe('emit', () => { + it('should call socket.emit', async () => { + const { socketConnection } = setup(); + const action = 'some-action'; + const data = { isOwnAction: true }; + doesConnectWork = true; + + await socketConnection.connect(); + socketConnection.emit(action, data); + + expect(ioMock.emit).toHaveBeenCalledWith(action, data); + }); + }); + + describe('if connection error occurs', () => { + it('should reject', async () => { + const { socketConnection } = setup(); + doesConnectWork = false; + + const err = new Error('connection failed'); + const triggerError = () => onceListeners.connect_error.forEach((listener) => listener(err)); + setTimeout(triggerError, 10); + + await expect(socketConnection.connect()).rejects.toThrow('Could not connect to socket server: connection failed'); + }); + }); + + describe('registerPromise', () => { + it("should register a promise and execute it's listener", async () => { + const { socketConnection } = setup(); + const action = 'some-action-success'; + const data = { isOwnAction: true }; + doesConnectWork = true; + + await socketConnection.connect(); + const mockResolve = jest.fn(); + const mockReject = jest.fn(); + socketConnection.registerPromise(action, mockResolve, mockReject); + onAnyListeners.forEach((l) => l(action, data)); + + expect(mockResolve).toHaveBeenCalledWith(data); + }); + }); + + describe('checkTimeouts', () => { + it('should reject the promise if the timeout is reached', () => { + const { socketConnection } = setup(); + const action = 'some-action-success'; + const reject = jest.fn(); + + socketConnection.registerPromise(action, jest.fn(), reject, -10); + socketConnection.checkTimeouts(); + + expect(reject).toHaveBeenCalled(); + }); + }); + + describe('ensureRunningTimeoutChecks', () => { + it('should set a timeout', () => { + const { socketConnection } = setup(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + socketConnection.ensureRunningTimeoutChecks(); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + }); + + describe('close', () => { + it('should disconnect the socket', async () => { + const { socketConnection } = setup(); + + await socketConnection.connect(); + + socketConnection.close(); + expect(socketConnection.isConnected()).toBe(false); + }); + }); +}); diff --git a/apps/server/src/modules/board/loadtest/socket-connection.ts b/apps/server/src/modules/board/loadtest/socket-connection.ts new file mode 100644 index 00000000000..146dd24545d --- /dev/null +++ b/apps/server/src/modules/board/loadtest/socket-connection.ts @@ -0,0 +1,175 @@ +import { io, Socket } from 'socket.io-client'; +import { v4 as uuid } from 'uuid'; +import { Callback, SocketConfiguration } from './types'; +import { useResponseTimes } from './helper/responseTimes.composable'; + +type RegisteredPromise = { event: string; resolve: Callback; reject: Callback; handle: string; startTime: number }; + +const { addResponseTime } = useResponseTimes(); + +export class SocketConnection { + private socket: Socket; + + private socketConfiguration: SocketConfiguration; + + private connected = false; + + private registeredPromises: Record = {}; + + private timeoutuntilList: { timeoutUntil: number; handle: string }[] = []; + + private checkerInterval: NodeJS.Timeout | undefined; + + private onError: Callback; + + constructor(socketConfiguration: SocketConfiguration, onError: Callback | undefined) { + this.socketConfiguration = socketConfiguration; + this.onError = onError ?? console.log; + const options = socketConfiguration.options || { + path: socketConfiguration.path, + withCredentials: true, + autoConnect: true, + forceNew: true, + }; + + const { token, baseUrl } = socketConfiguration; + if (token) { + options.extraHeaders = { cookie: ` USER_TIMEZONE=Europe/Berlin; jwt=${token}` }; + } + + this.socket = io(baseUrl, options); + } + + async connect() { + this.ensureRunningTimeoutChecks(); + return new Promise((resolve, reject) => { + let handle: NodeJS.Timeout | undefined; + if (this.socket.connected) { + /* istanbul ignore next */ + resolve(true); + } + + this.socket.on('connect', () => { + this.connected = true; + if (handle) clearTimeout(handle); + resolve(true); + }); + + this.socket.on('disconnect', () => { + /* istanbul ignore next */ + this.connected = false; + }); + + this.socket.once('connect_error', (err) => { + if (handle) clearTimeout(handle); + this.onError(`Could not connect to socket server: ${err.message}`); + reject(new Error(`Could not connect to socket server: ${err.message}`)); + }); + + this.socket.onAny((event: string, data: { isOwnAction: boolean }) => { + if (data.isOwnAction === true) { + this.processRegisteredPromises(event, data); + } + }); + + this.socket.connect(); + + handle = setTimeout(() => { + /* istanbul ignore next */ + if (!this.connected) { + reject(new Error('Timeout: could not connect to socket server')); + } + }, this.socketConfiguration.connectTimeout ?? 5000); + }); + } + + emit = (event: string, data: unknown) => { + this.socket.emit(event, data); + }; + + // eslint-disable-next-line arrow-body-style + emitAndWait = async (actionPrefix: string, payload: unknown, timeoutMs = 5000) => { + /* istanbul ignore next */ + return new Promise((resolve, reject) => { + this.socket.emit(`${actionPrefix}-request`, payload); + this.registerPromise(`${actionPrefix}-success`, resolve, reject, timeoutMs); + }); + }; + + ensureRunningTimeoutChecks() { + if (!this.checkerInterval) { + this.checkerInterval = setInterval(() => this.checkTimeouts(), 1000); + } + } + + checkTimeouts() { + const now = performance.now(); + while (this.timeoutuntilList.length > 0 && this.timeoutuntilList[0]?.timeoutUntil < now) { + const first = this.timeoutuntilList.shift(); + if (first) { + const { handle } = first; + const promise = this.getRegisteredPromise(handle); + if (promise) { + this.unregisterPromise(handle, handle); + this.unregisterTimeout(handle); + const message = `Timeout exceeded: ${promise.event}`; + this.onError(message); + promise.reject(new Error(message)); + } + } + } + } + + processRegisteredPromises(event: string, data: unknown) { + const promises = this.registeredPromises[event]; + if (promises) { + for (const promise of promises) { + const responseTime = performance.now() - promises[0].startTime; + this.unregisterPromise(event, promise.handle); + this.unregisterTimeout(promise.handle); + addResponseTime({ action: event, responseTime }); + promise.resolve(data); + } + } + } + + registerPromise(successEvent: string, resolve: Callback, reject: Callback, timeoutMs = 5000) { + const startTime = performance.now(); + const handle = uuid(); + const failureEvent = successEvent.replace('-success', '-failure'); + this.registeredPromises[successEvent] = this.registeredPromises[successEvent] ?? []; + this.registeredPromises[successEvent].push({ resolve, reject, handle, startTime, event: successEvent }); + this.registeredPromises[failureEvent] = this.registeredPromises[failureEvent] ?? []; + this.registeredPromises[failureEvent].push({ resolve: reject, reject, handle, startTime, event: failureEvent }); + this.registerTimeout(handle, startTime + timeoutMs); + return handle; + } + + getRegisteredPromise(handle: string) { + const promises = Object.values(this.registeredPromises).flat(); + return promises.find((p) => p.handle === handle); + } + + unregisterPromise(successEvent: string, handle: string) { + const failureEvent = successEvent.replace('-success', '-failure'); + this.registeredPromises[successEvent] = this.registeredPromises[successEvent]?.filter((p) => p.handle !== handle); + this.registeredPromises[failureEvent] = this.registeredPromises[failureEvent]?.filter((p) => p.handle !== handle); + } + + registerTimeout(handle: string, timeoutUntil: number) { + this.timeoutuntilList.push({ handle, timeoutUntil }); + } + + unregisterTimeout(handle: string) { + this.timeoutuntilList = this.timeoutuntilList.filter((t) => t.handle !== handle); + } + + isConnected() { + return this.connected; + } + + close() { + this.socket.close(); + this.connected = false; + } +} diff --git a/apps/server/src/modules/board/loadtest/types.ts b/apps/server/src/modules/board/loadtest/types.ts new file mode 100644 index 00000000000..16b17f05d36 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/types.ts @@ -0,0 +1,56 @@ +import { ManagerOptions, SocketOptions } from 'socket.io-client'; +import type { SocketConnectionManager } from './socket-connection-manager'; +import type { BoardLoadTest } from './board-load-test'; + +export type UrlConfiguration = { + websocket: string; + api: string; + web: string; +}; + +export type UserProfile = { + name: string; + sleepMs: number; + isActive: boolean; +}; + +export type UserProfileWithAmount = { + name: string; + sleepMs: number; + isActive: boolean; + amount: number; +}; + +export type ClassDefinition = { + name: string; + users: UserProfileWithAmount[]; +}; + +export type ClassDefinitionWithAmount = { + classDefinition: ClassDefinition; + amount: number; +}; + +export type Configuration = { + amount: number; + classDefinition: ClassDefinition; +}; + +export type ResponseTimeRecord = { + action: string; + responseTime: number; +}; + +export type SocketConfiguration = { + path: string; + baseUrl: string; + token: string; + connectTimeout?: number; + options?: Partial; +}; + +export type Callback = (...args: unknown[]) => void; + +export interface CreateBoardLoadTest { + (socketConnectionManager: SocketConnectionManager, onError: Callback): BoardLoadTest; +} diff --git a/apps/server/src/modules/board/metrics/metrics.service.ts b/apps/server/src/modules/board/metrics/metrics.service.ts index b2a54d5ce75..99121a05290 100644 --- a/apps/server/src/modules/board/metrics/metrics.service.ts +++ b/apps/server/src/modules/board/metrics/metrics.service.ts @@ -92,7 +92,7 @@ export class MetricsService { summary = new Summary({ name: `sc_boards_execution_time_${actionName}`, help: 'Average execution time of a specific action in milliseconds', - maxAgeSeconds: 600, + maxAgeSeconds: 30, ageBuckets: 5, percentiles: [0.01, 0.1, 0.5, 0.9, 0.99], pruneAgedBuckets: true, @@ -100,7 +100,6 @@ export class MetricsService { this.executionTimesSummary.set(actionName, summary); register.registerMetric(summary); } - console.log(actionName, `executionTime: ${value.toFixed(3)} ms`); summary.observe(value); } @@ -121,7 +120,6 @@ export class MetricsService { register.registerMetric(counter); } counter.inc(); - console.log(actionName, `count increased`); } public incrementActionGauge(actionName: string): void { @@ -129,7 +127,7 @@ export class MetricsService { if (!counter) { counter = new Gauge({ - name: `sc_boards_count2_${actionName}`, + name: `sc_boards_actions_gauge_${actionName}`, help: 'Number of calls for a specific action per minute', // async collect() { // // Invoked when the registry collects its metrics' values. @@ -141,6 +139,5 @@ export class MetricsService { register.registerMetric(counter); } counter.inc(); - console.log(actionName, `count increased`); } } diff --git a/apps/server/src/modules/board/repo/entity/board-node.entity.ts b/apps/server/src/modules/board/repo/entity/board-node.entity.ts index a19ea9cea70..3385f29b8a8 100644 --- a/apps/server/src/modules/board/repo/entity/board-node.entity.ts +++ b/apps/server/src/modules/board/repo/entity/board-node.entity.ts @@ -2,8 +2,14 @@ import { Embedded, Entity, Enum, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId, InputFormat } from '@shared/domain/types'; import { ObjectIdType } from '@shared/repo/types/object-id.type'; -import { AnyBoardNode, BoardLayout, BoardNodeType, ROOT_PATH } from '../../domain'; -import { MediaBoardColors } from '../../domain/media-board/types'; +import { + AnyBoardNode, + BoardLayout, + BoardNodeType, + ContentElementType, + MediaBoardColors, + ROOT_PATH, +} from '../../domain'; import type { BoardNodeEntityProps } from '../types'; import { Context } from './embeddables'; @@ -31,7 +37,7 @@ export class BoardNodeEntity extends BaseEntityWithTimestamps implements BoardNo @Property({ persist: false }) domainObject: AnyBoardNode | undefined; - // Card, Column, ColumnBoard, LinkElement, MedialLine + // Card, Column, ColumnBoard, LinkElement, MedialLine, DeletedElement // -------------------------------------------------------------------------- @Property({ nullable: true }) title: string | undefined; @@ -107,4 +113,9 @@ export class BoardNodeEntity extends BaseEntityWithTimestamps implements BoardNo @Property({ type: 'MediaBoardColors', nullable: true }) backgroundColor: MediaBoardColors | undefined; + + // DeletedElement + // -------------------------------------------------------------------------- + @Enum({ type: 'ContentElementType', nullable: true }) + deletedElementType: ContentElementType | undefined; } diff --git a/apps/server/src/modules/board/repo/types/board-node-entity-props.ts b/apps/server/src/modules/board/repo/types/board-node-entity-props.ts index 40c756264d0..29073bc3a2e 100644 --- a/apps/server/src/modules/board/repo/types/board-node-entity-props.ts +++ b/apps/server/src/modules/board/repo/types/board-node-entity-props.ts @@ -6,6 +6,7 @@ import type { CollaborativeTextEditorElementProps, ColumnBoardProps, ColumnProps, + DeletedElementProps, DrawingElementProps, ExternalToolElementProps, FileElementProps, @@ -55,4 +56,5 @@ export interface BoardNodeEntityProps ComponentProps, ComponentProps, ComponentProps, - ComponentProps {} + ComponentProps, + ComponentProps {} diff --git a/apps/server/src/modules/board/service/board-node.service.spec.ts b/apps/server/src/modules/board/service/board-node.service.spec.ts index 4564dcb699f..90e2cd7fb3c 100644 --- a/apps/server/src/modules/board/service/board-node.service.spec.ts +++ b/apps/server/src/modules/board/service/board-node.service.spec.ts @@ -1,9 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { Card, ColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; -import { columnBoardFactory, richTextElementFactory } from '../testing'; +import { + cardFactory, + columnBoardFactory, + deletedElementFactory, + externalToolElementFactory, + richTextElementFactory, +} from '../testing'; import { BoardNodeService } from './board-node.service'; import { BoardNodeDeleteHooksService, ContentElementUpdateService } from './internal'; @@ -147,4 +155,93 @@ describe(BoardNodeService.name, () => { }); }); }); + + describe('replace', () => { + describe('when replacing a node', () => { + const setup = () => { + const oldNode = externalToolElementFactory.build(); + const newNode = deletedElementFactory.build(); + const parentNode = cardFactory.build(); + parentNode.addChild(oldNode); + + boardNodeRepo.findById.mockResolvedValueOnce(new Card({ ...parentNode.getTrueProps() })); + + return { + parentNode, + oldNode, + newNode, + }; + }; + + it('should add the new node', async () => { + const { parentNode, oldNode, newNode } = setup(); + + await service.replace(oldNode, newNode); + + expect(boardNodeRepo.save).toHaveBeenCalledWith( + new Card({ ...parentNode.getTrueProps(), children: [oldNode, newNode] }) + ); + }); + + it('should delete the old node', async () => { + const { oldNode, newNode } = setup(); + + await service.replace(oldNode, newNode); + + expect(boardNodeRepo.delete).toHaveBeenCalledWith(oldNode); + }); + }); + + describe('when the node has no parent', () => { + const setup = () => { + const oldNode = externalToolElementFactory.build(); + const newNode = deletedElementFactory.build(); + + return { + oldNode, + newNode, + }; + }; + + it('should throw an error', async () => { + const { oldNode, newNode } = setup(); + + await expect(service.replace(oldNode, newNode)).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('findElementsByContextExternalToolId', () => { + describe('when finding a node by its context external tool id', () => { + const setup = () => { + const contextExternalToolId = new ObjectId().toHexString(); + const node = externalToolElementFactory.build({ + contextExternalToolId, + }); + + boardNodeRepo.findByContextExternalToolIds.mockResolvedValueOnce([node]); + + return { + node, + contextExternalToolId, + }; + }; + + it('should search by the context external tool id', async () => { + const { contextExternalToolId } = setup(); + + await service.findElementsByContextExternalToolId(contextExternalToolId); + + expect(boardNodeRepo.findByContextExternalToolIds).toHaveBeenCalledWith([contextExternalToolId]); + }); + + it('should return the node', async () => { + const { node, contextExternalToolId } = setup(); + + const result = await service.findElementsByContextExternalToolId(contextExternalToolId); + + expect(result).toEqual([node]); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-node.service.ts b/apps/server/src/modules/board/service/board-node.service.ts index 0ac96b627b1..52c8279706c 100644 --- a/apps/server/src/modules/board/service/board-node.service.ts +++ b/apps/server/src/modules/board/service/board-node.service.ts @@ -52,6 +52,19 @@ export class BoardNodeService { await this.contentElementUpdateService.updateContent(element, content); } + async replace(oldNode: AnyBoardNode, newNode: AnyBoardNode): Promise { + const parent: AnyBoardNode | undefined = await this.findParent(oldNode); + + if (!parent) { + throw new NotFoundException(`Unable to find a parent node for ${oldNode.id}`); + } + + parent.addChild(newNode); + await this.boardNodeRepo.save(parent); + + await this.delete(oldNode); + } + async move(child: AnyBoardNode, targetParent: AnyBoardNode, targetPosition?: number): Promise { const saveList: AnyBoardNode[] = []; @@ -128,6 +141,12 @@ export class BoardNodeService { return rootNode; } + public async findElementsByContextExternalToolId(contextExternalToolId: EntityId): Promise { + const elements: AnyBoardNode[] = await this.boardNodeRepo.findByContextExternalToolIds([contextExternalToolId]); + + return elements; + } + async delete(boardNode: AnyBoardNode): Promise { const parent = await this.findParent(boardNode); if (parent) { diff --git a/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.spec.ts b/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.spec.ts new file mode 100644 index 00000000000..696648578bf --- /dev/null +++ b/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.spec.ts @@ -0,0 +1,78 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolDeletedEvent } from '../../../tool/context-external-tool/domain'; +import { ContentElementType, DeletedElement, ROOT_PATH } from '../../domain'; +import { externalToolElementFactory } from '../../testing'; +import { BoardNodeService } from '../board-node.service'; +import { ContextExternalToolDeletedEventHandlerService } from './context-external-tool-deleted-event-handler.service'; + +describe(ContextExternalToolDeletedEventHandlerService.name, () => { + let module: TestingModule; + let service: ContextExternalToolDeletedEventHandlerService; + + let boardNodeService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ContextExternalToolDeletedEventHandlerService, + { + provide: BoardNodeService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ContextExternalToolDeletedEventHandlerService); + boardNodeService = module.get(BoardNodeService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handle', () => { + describe('when a context external tool gets deleted', () => { + const setup = () => { + const contextExternalToolId = new ObjectId().toHexString(); + const event = new ContextExternalToolDeletedEvent({ id: contextExternalToolId, title: 'Delete me' }); + const externalToolElement = externalToolElementFactory.build({ + contextExternalToolId, + }); + + boardNodeService.findElementsByContextExternalToolId.mockResolvedValueOnce([externalToolElement]); + + return { + event, + externalToolElement, + }; + }; + + it('should replace the context external tool element with a deleted element', async () => { + const { event, externalToolElement } = setup(); + + await service.handle(event); + + expect(boardNodeService.replace).toHaveBeenCalledWith( + externalToolElement, + new DeletedElement({ + id: expect.any(String), + path: ROOT_PATH, + level: 0, + position: 0, + children: [], + createdAt: expect.any(Date) as unknown as Date, + updatedAt: expect.any(Date) as unknown as Date, + deletedElementType: ContentElementType.EXTERNAL_TOOL, + title: event.title, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.ts b/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.ts new file mode 100644 index 00000000000..21a979dbc91 --- /dev/null +++ b/apps/server/src/modules/board/service/event/context-external-tool-deleted-event-handler.service.ts @@ -0,0 +1,32 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { ContextExternalToolDeletedEvent } from '@modules/tool/context-external-tool/domain'; +import { Injectable } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { AnyBoardNode, ContentElementType, DeletedElement, ROOT_PATH } from '../../domain'; +import { BoardNodeService } from '../board-node.service'; + +@Injectable() +@EventsHandler(ContextExternalToolDeletedEvent) +export class ContextExternalToolDeletedEventHandlerService implements IEventHandler { + constructor(private readonly boardNodeService: BoardNodeService) {} + + public async handle(event: ContextExternalToolDeletedEvent) { + const elements: AnyBoardNode[] = await this.boardNodeService.findElementsByContextExternalToolId(event.id); + + elements.map(async (element: AnyBoardNode): Promise => { + const placeholder: DeletedElement = new DeletedElement({ + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedElementType: ContentElementType.EXTERNAL_TOOL, + title: event.title, + }); + + await this.boardNodeService.replace(element, placeholder); + }); + } +} diff --git a/apps/server/src/modules/board/service/event/index.ts b/apps/server/src/modules/board/service/event/index.ts index fb9b581be6a..3be8dbc381d 100644 --- a/apps/server/src/modules/board/service/event/index.ts +++ b/apps/server/src/modules/board/service/event/index.ts @@ -1 +1,2 @@ export { UserDeletedEventHandlerService } from './user-deleted-event-handler.service'; +export { ContextExternalToolDeletedEventHandlerService } from './context-external-tool-deleted-event-handler.service'; diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index a660a008ec9..1c6c4bfafe9 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -13,6 +13,7 @@ import { collaborativeTextEditorFactory, columnBoardFactory, columnFactory, + deletedElementFactory, drawingElementFactory, externalToolElementFactory, fileElementFactory, @@ -94,6 +95,7 @@ describe(BoardNodeCopyService.name, () => { jest.spyOn(service, 'copyMediaBoard').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyMediaLine').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyMediaExternalToolElement').mockResolvedValue(mockStatus); + jest.spyOn(service, 'copyDeletedElement').mockResolvedValue(mockStatus); return { copyContext, mockStatus }; }; @@ -265,6 +267,18 @@ describe(BoardNodeCopyService.name, () => { expect(result).toEqual(mockStatus); }); }); + + describe('when called with deleted element', () => { + it('should copy deleted element', async () => { + const { copyContext, mockStatus } = setup(); + const node = deletedElementFactory.build(); + + const result = await service.copy(node, copyContext); + + expect(service.copyDeletedElement).toHaveBeenCalledWith(node, copyContext); + expect(result).toEqual(mockStatus); + }); + }); }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 87e09b13a41..66bd8b6ea0d 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -15,6 +15,7 @@ import { CollaborativeTextEditorElement, Column, ColumnBoard, + DeletedElement, DrawingElement, ExternalToolElement, FileElement, @@ -27,6 +28,7 @@ import { collaborativeTextEditorFactory, columnBoardFactory, columnFactory, + deletedElementFactory, drawingElementFactory, externalToolElementFactory, fileElementFactory, @@ -604,4 +606,24 @@ describe(BoardNodeCopyService.name, () => { ); }); }); + + describe('copy deleted element', () => { + const setup = () => { + const { copyContext } = setupContext(); + const deletedElement = deletedElementFactory.build(); + + return { + copyContext, + deletedElement, + }; + }; + + it('should copy the node', async () => { + const { copyContext, deletedElement } = setup(); + + const result = await service.copyDeletedElement(deletedElement, copyContext); + + expect(result.copyEntity).toBeInstanceOf(DeletedElement); + }); + }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index 4686f6cfcae..3870411bac9 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -14,6 +14,7 @@ import { CollaborativeTextEditorElement, Column, ColumnBoard, + DeletedElement, DrawingElement, ExternalToolElement, FileElement, @@ -79,6 +80,9 @@ export class BoardNodeCopyService { case BoardNodeType.COLLABORATIVE_TEXT_EDITOR: result = await this.copyCollaborativeTextEditorElement(boardNode as CollaborativeTextEditorElement, context); break; + case BoardNodeType.DELETED_ELEMENT: + result = await this.copyDeletedElement(boardNode as DeletedElement, context); + break; case BoardNodeType.MEDIA_BOARD: result = await this.copyMediaBoard(boardNode as MediaBoard, context); break; @@ -242,7 +246,7 @@ export class BoardNodeCopyService { const result: CopyStatus = { copyEntity: copy, type: CopyElementType.DRAWING_ELEMENT, - status: CopyStatusEnum.SUCCESS, + status: CopyStatusEnum.PARTIAL, }; return Promise.resolve(result); @@ -365,6 +369,22 @@ export class BoardNodeCopyService { return Promise.resolve(result); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async copyDeletedElement(original: DeletedElement, context: CopyContext): Promise { + const copy = new DeletedElement({ + ...original.getProps(), + ...this.buildSpecificProps([]), + }); + + const result: CopyStatus = { + copyEntity: copy, + type: CopyElementType.DELETED_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }; + + return Promise.resolve(result); + } + // ---- private async copyChildrenOf(boardNode: AnyBoardNode, context: CopyContext): Promise { diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts index 95cb90c9379..0a6792bdc46 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts @@ -6,7 +6,7 @@ import { CourseRepo } from '@shared/repo/course/course.repo'; import { courseFactory, setupEntities, userDoFactory } from '@shared/testing'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client/service/files-storage-client.service'; -import { BoardExternalReferenceType, ColumnBoard } from '../../domain'; +import { BoardExternalReferenceType } from '../../domain'; import { columnBoardFactory } from '../../testing'; import { BoardNodeService } from '../board-node.service'; import { ColumnBoardCopyService } from './column-board-copy.service'; @@ -22,6 +22,7 @@ describe(ColumnBoardCopyService.name, () => { let courseRepo: DeepMocked; let userService: DeepMocked; let boardNodeCopyService: DeepMocked; + let columnBoardTitleService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -51,6 +52,10 @@ describe(ColumnBoardCopyService.name, () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: ColumnBoardTitleService, + useValue: createMock(), + }, ], }).compile(); @@ -59,6 +64,7 @@ describe(ColumnBoardCopyService.name, () => { courseRepo = module.get(CourseRepo); userService = module.get(UserService); boardNodeCopyService = module.get(BoardNodeCopyService); + columnBoardTitleService = module.get(ColumnBoardTitleService); await setupEntities(); }); @@ -71,40 +77,183 @@ describe(ColumnBoardCopyService.name, () => { await module.close(); }); - const setup = () => { - const userId = new ObjectId().toHexString(); - const user = userDoFactory.build({ id: userId }); - userService.findById.mockResolvedValueOnce(user); - const course = courseFactory.buildWithId(); - courseRepo.findById.mockResolvedValueOnce(course); - const originalBoard = columnBoardFactory.build({ - context: { id: course.id, type: BoardExternalReferenceType.Course }, + describe('copyColumnBoard', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ id: userId }); + userService.findById.mockResolvedValueOnce(user); + const course = courseFactory.buildWithId(); + courseRepo.findById.mockResolvedValueOnce(course); + const originalBoard = columnBoardFactory.build({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + boardNodeService.findByClassAndId.mockResolvedValueOnce(originalBoard); + + const boardCopy = columnBoardFactory.build({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + const status: CopyStatus = { + copyEntity: boardCopy, + type: CopyElementType.BOARD, + status: CopyStatusEnum.SUCCESS, + }; + boardNodeCopyService.copy.mockResolvedValueOnce(status); + + return { originalBoard, userId }; + }; + + it('should find the original board', async () => { + const { originalBoard, userId } = setup(); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(boardNodeService.findByClassAndId).toHaveBeenCalled(); + }); + + it('should find the user', async () => { + const { originalBoard, userId } = setup(); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(userService.findById).toHaveBeenCalled(); }); - boardNodeService.findByClassAndId.mockResolvedValueOnce(originalBoard); - const boardCopy = columnBoardFactory.build({ - context: { id: course.id, type: BoardExternalReferenceType.Course }, + + it('should find the course', async () => { + const { originalBoard, userId } = setup(); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(courseRepo.findById).toHaveBeenCalled(); + }); + + it('should call service to copy the board', async () => { + const { originalBoard, userId } = setup(); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + copyTitle: 'Another Title', + }); + + expect(boardNodeCopyService.copy).toHaveBeenCalled(); }); - const status: CopyStatus = { - copyEntity: boardCopy, - type: CopyElementType.BOARD, - status: CopyStatusEnum.SUCCESS, - }; - boardNodeCopyService.copy.mockResolvedValueOnce(status); - return { originalBoard, userId }; - }; + it('should set the title of the copied board', async () => { + const { originalBoard, userId } = setup(); + const copyTitle = 'Another Title'; - it('should copy the board', async () => { - const { originalBoard, userId } = setup(); + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + copyTitle, + }); - const result = await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - copyTitle: 'Another Title', + expect(boardNodeService.addRoot).toHaveBeenCalledWith(expect.objectContaining({ title: copyTitle })); }); - expect(boardNodeCopyService.copy).toHaveBeenCalled(); - expect((result.copyEntity as ColumnBoard).title).toBe('Another Title'); + it('should derive the title of the copied board', async () => { + const { originalBoard, userId } = setup(); + const derivedTitle = 'Derived Title'; + columnBoardTitleService.deriveColumnBoardTitle.mockResolvedValueOnce(derivedTitle); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(boardNodeService.addRoot).toHaveBeenCalledWith(expect.objectContaining({ title: derivedTitle })); + }); + + it('should set the context of the copied board', async () => { + const { originalBoard, userId } = setup(); + const destinationExternalReference = { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.Course, + }; + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId, + }); + + expect(boardNodeService.addRoot).toHaveBeenCalledWith( + expect.objectContaining({ context: destinationExternalReference }) + ); + }); + + it('should return the copy status', async () => { + const { originalBoard, userId } = setup(); + const copyStatus = await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(copyStatus).toBeDefined(); + expect(copyStatus.copyEntity).toBeDefined(); + }); + + it('should not affect the original board', async () => { + const { originalBoard, userId } = setup(); + const copyStatus = await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }); + + expect(copyStatus.originalEntity).toBe(originalBoard); + }); + }); + + describe('when the copy response is not a ColumnBoard', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ id: userId }); + userService.findById.mockResolvedValueOnce(user); + const course = courseFactory.buildWithId(); + courseRepo.findById.mockResolvedValueOnce(course); + const originalBoard = columnBoardFactory.build({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + boardNodeService.findByClassAndId.mockResolvedValueOnce(originalBoard); + + return { originalBoard, userId }; + }; + + it('should throw an error if the board is not a column board', async () => { + const { originalBoard, userId } = setup(); + + const boardCopy = { ...originalBoard, id: new ObjectId().toHexString(), type: 'not-a-column-board' }; + const status: CopyStatus = { + copyEntity: boardCopy, + type: CopyElementType.BOARD, + status: CopyStatusEnum.SUCCESS, + }; + boardNodeCopyService.copy.mockResolvedValueOnce(status); + + await expect( + service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference: originalBoard.context, + userId, + }) + ).rejects.toThrowError('expected copy of columnboard to be a columnboard'); + }); }); }); diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts index ff6af9e8733..80136e2f832 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts @@ -63,6 +63,7 @@ export class ColumnBoardCopyService { } copyStatus.copyEntity.context = props.destinationExternalReference; await this.boardNodeService.addRoot(copyStatus.copyEntity); + copyStatus.originalEntity = originalBoard; return copyStatus; } diff --git a/apps/server/src/modules/board/testing/deleted-element.factory.ts b/apps/server/src/modules/board/testing/deleted-element.factory.ts new file mode 100644 index 00000000000..f71b1263585 --- /dev/null +++ b/apps/server/src/modules/board/testing/deleted-element.factory.ts @@ -0,0 +1,20 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { ContentElementType, DeletedElement, DeletedElementProps, ROOT_PATH } from '../domain'; + +export const deletedElementFactory = BaseFactory.define( + DeletedElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + title: `Title #${sequence}`, + deletedElementType: ContentElementType.EXTERNAL_TOOL, + }; + } +); diff --git a/apps/server/src/modules/board/testing/index.ts b/apps/server/src/modules/board/testing/index.ts index 7e6e20039e8..898a9b9f965 100644 --- a/apps/server/src/modules/board/testing/index.ts +++ b/apps/server/src/modules/board/testing/index.ts @@ -16,3 +16,4 @@ export * from './media-line.factory'; export * from './rich-text-element.factory'; export * from './submission-container-element.factory'; export * from './submission-item.factory'; +export * from './deleted-element.factory'; 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 75c2dafc23e..26e8b220931 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -315,7 +315,7 @@ describe(BoardUc.name, () => { await uc.createColumn(user.id, board.id); - expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, board.id); + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, board.id, 1); }); it('should call the service to check the permissions', async () => { diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index a0425ffa600..a48be369c1c 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -88,7 +88,7 @@ export class BoardUc { async createColumn(userId: EntityId, boardId: EntityId): Promise { this.logger.debug({ action: 'createColumn', userId, boardId }); - const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); + const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId, 1); await this.boardPermissionService.checkPermission(userId, board, Action.write); const column = this.boardNodeFactory.buildColumn(); diff --git a/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.spec.ts b/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.spec.ts index 77b958f9c7a..875be40e91e 100644 --- a/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.spec.ts +++ b/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; -import { ICurrentUser } from '@modules/authentication'; import { CollaborativeStorageController } from '@modules/collaborative-storage/controller/collaborative-storage.controller'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; import { Test, TestingModule } from '@nestjs/testing'; +import { currentUserFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; describe('CollaborativeStorage Controller', () => { @@ -28,8 +28,10 @@ describe('CollaborativeStorage Controller', () => { describe('Update TeamPermissions For Role', () => { it('should call the UC', async () => { + const currentUser = currentUserFactory.build(); + await controller.updateTeamPermissionsForRole( - { userId: 'userId' } as ICurrentUser, + currentUser, { teamId: 'testTeam', roleId: 'testRole' }, { read: false, write: false, create: false, delete: false, share: false } ); diff --git a/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.ts b/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.ts index 4e11b3c46bd..df17fb7abc3 100644 --- a/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.ts +++ b/apps/server/src/modules/collaborative-storage/controller/collaborative-storage.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Param, Patch } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { LegacyLogger } from '@src/core/logger'; @@ -11,7 +11,7 @@ import { TeamRoleDto } from './dto/team-role.params'; * */ @ApiTags('Collaborative-Storage') -@Authenticate('jwt') +@JwtAuthentication() @Controller('collaborative-storage') export class CollaborativeStorageController { constructor(private readonly teamStorageUc: CollaborativeStorageUc, private logger: LegacyLogger) { diff --git a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts index c6e51966877..b3c1dca34a6 100644 --- a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts +++ b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Delete, ForbiddenException, Get, NotFoundException, Param, Res } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; @@ -9,7 +9,7 @@ import { GetCollaborativeTextEditorForParentParams } from './dto/get-collaborati import { CollaborativeTextEditorMapper } from './mapper/collaborative-text-editor.mapper'; @ApiTags('CollaborativeTextEditor') -@Authenticate('jwt') +@JwtAuthentication() @Controller('collaborative-text-editor') export class CollaborativeTextEditorController { constructor(private readonly collaborativeTextEditorUc: CollaborativeTextEditorUc) {} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-api.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge-api.module.ts index 922d013aa57..317f705ec7a 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-api.module.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-api.module.ts @@ -4,8 +4,11 @@ import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { config } from './common-cartridge.config'; +import { CommonCartridgeModule } from './common-cartridge.module'; +import { CommonCartridgeController } from './controller/common-cartridge.controller'; @Module({ - imports: [CoreModule, HttpModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + imports: [CoreModule, HttpModule, ConfigModule.forRoot(createConfigModuleOptions(config)), CommonCartridgeModule], + controllers: [CommonCartridgeController], }) export class CommonCartridgeApiModule {} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.config.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge.config.spec.ts index 54508b0a86f..2e15af59e9e 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge.config.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge.config.spec.ts @@ -6,7 +6,8 @@ describe('commonCartridgeConfig', () => { const result = config(); expect(result).toStrictEqual({ - NEST_LOG_LEVEL: 'error', + NEST_LOG_LEVEL: expect.any(String), + INCOMING_REQUEST_TIMEOUT: expect.any(Number), }); }); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.config.ts b/apps/server/src/modules/common-cartridge/common-cartridge.config.ts index ae7d8bf98fc..724e2c558e0 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge.config.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge.config.ts @@ -2,10 +2,12 @@ import { Configuration } from '@hpi-schul-cloud/commons'; export interface CommonCartridgeConfig { NEST_LOG_LEVEL: string; + INCOMING_REQUEST_TIMEOUT: number; } const commonCartridgeConfig: CommonCartridgeConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + INCOMING_REQUEST_TIMEOUT: Configuration.get('FILES_STORAGE__INCOMING_REQUEST_TIMEOUT') as number, }; export function config(): CommonCartridgeConfig { diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts new file mode 100644 index 00000000000..0e0e929c7d7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts @@ -0,0 +1,27 @@ +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { FilesStorageClientModule } from '@modules/files-storage-client'; +import { Module } from '@nestjs/common'; +import { ALL_ENTITIES } from '@shared/domain/entity'; +import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; +import { RabbitMQWrapperModule } from '@src/infra/rabbitmq'; +import { defaultMikroOrmOptions } from '../server'; +import { CommonCartridgeExportService } from './service/common-cartridge-export.service'; +import { CommonCartridgeUc } from './uc/common-cartridge.uc'; + +@Module({ + imports: [ + RabbitMQWrapperModule, + FilesStorageClientModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: ALL_ENTITIES, + }), + ], + providers: [CommonCartridgeUc, CommonCartridgeExportService], + exports: [CommonCartridgeUc], +}) +export class CommonCartridgeModule {} diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts new file mode 100644 index 00000000000..d6712b12b37 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts @@ -0,0 +1,56 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; +import { CommonCartridgeController } from './common-cartridge.controller'; +import { CourseFileIdsResponse, ExportCourseParams } from './dto'; + +describe('CommonCartridgeController', () => { + let module: TestingModule; + let sut: CommonCartridgeController; + let commonCartridgeUcMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [CommonCartridgeController], + providers: [ + { + provide: CommonCartridgeUc, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(CommonCartridgeController); + commonCartridgeUcMock = module.get(CommonCartridgeUc); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('exportCourse', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const request = new ExportCourseParams(); + const expected = new CourseFileIdsResponse([]); + + Reflect.set(request, 'parentId', courseId); + commonCartridgeUcMock.exportCourse.mockResolvedValue(expected); + + return { request, expected }; + }; + + it('should return a list of found FileRecords', async () => { + const { request, expected } = setup(); + + const result = await sut.exportCourse(request); + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts new file mode 100644 index 00000000000..a91abf6f2b3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; +import { CourseFileIdsResponse, ExportCourseParams } from './dto'; + +@ApiTags('common-cartridge') +@Controller('common-cartridge') +export class CommonCartridgeController { + constructor(private readonly commonCartridgeUC: CommonCartridgeUc) {} + + @Get('export/:parentId') + public async exportCourse(@Param() exportCourseParams: ExportCourseParams): Promise { + return this.commonCartridgeUC.exportCourse(exportCourseParams.parentId); + } +} diff --git a/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts new file mode 100644 index 00000000000..a93c604f793 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { IsMongoId } from 'class-validator'; + +export class ExportCourseParams { + @IsMongoId() + @ApiProperty() + public readonly parentId!: EntityId; +} diff --git a/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.response.ts b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.response.ts new file mode 100644 index 00000000000..672e1c6433a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CourseFileIdsResponse { + constructor(fileIds: string[]) { + this.fileIds = fileIds; + } + + @ApiProperty({ + type: [String], + description: 'Array of file ids', + }) + public readonly fileIds!: string[]; +} diff --git a/apps/server/src/modules/common-cartridge/controller/dto/index.ts b/apps/server/src/modules/common-cartridge/controller/dto/index.ts new file mode 100644 index 00000000000..e93173f89f7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/index.ts @@ -0,0 +1,2 @@ +export * from './common-cartridge.params'; +export * from './common-cartridge.response'; diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts new file mode 100644 index 00000000000..571a73b0d6a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CommonCartridgeExportService } from './common-cartridge-export.service'; + +describe('CommonCartridgeExportService', () => { + let module: TestingModule; + let sut: CommonCartridgeExportService; + let filesStorageServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonCartridgeExportService, + { + provide: FilesStorageClientAdapterService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(CommonCartridgeExportService); + filesStorageServiceMock = module.get(FilesStorageClientAdapterService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('findCourseFileRecords', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const expected = []; + + filesStorageServiceMock.listFilesOfParent.mockResolvedValue([]); + + return { courseId, expected }; + }; + + it('should return a list of FileRecords', async () => { + const { courseId, expected } = setup(); + + const result = await sut.findCourseFileRecords(courseId); + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts new file mode 100644 index 00000000000..15918e20675 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts @@ -0,0 +1,13 @@ +import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CommonCartridgeExportService { + constructor(private readonly filesService: FilesStorageClientAdapterService) {} + + public async findCourseFileRecords(courseId: string): Promise { + const courseFiles = await this.filesService.listFilesOfParent(courseId); + + return courseFiles; + } +} diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts new file mode 100644 index 00000000000..c6c68e4c91b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts @@ -0,0 +1,54 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CourseFileIdsResponse } from '../controller/dto'; +import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; +import { CommonCartridgeUc } from './common-cartridge.uc'; + +describe('CommonCartridgeUc', () => { + let module: TestingModule; + let sut: CommonCartridgeUc; + let commonCartridgeExportServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonCartridgeUc, + { + provide: CommonCartridgeExportService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(CommonCartridgeUc); + commonCartridgeExportServiceMock = module.get(CommonCartridgeExportService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('exportCourse', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const expected = new CourseFileIdsResponse([]); + + commonCartridgeExportServiceMock.findCourseFileRecords.mockResolvedValue([]); + + return { courseId, expected }; + }; + + it('should return a list of found FileRecords', async () => { + const { courseId, expected } = setup(); + + const result = await sut.exportCourse(courseId); + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts new file mode 100644 index 00000000000..d00ec42c92d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { CourseFileIdsResponse } from '../controller/dto'; +import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; + +@Injectable() +export class CommonCartridgeUc { + constructor(private readonly exportService: CommonCartridgeExportService) {} + + public async exportCourse(courseId: EntityId): Promise { + const files = await this.exportService.findCourseFileRecords(courseId); + const response = new CourseFileIdsResponse(files.map((file) => file.id)); + + return response; + } +} diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 27416d40303..703ab28aa56 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -20,6 +20,7 @@ export enum CopyElementType { CONTENT = 'CONTENT', COURSE = 'COURSE', COURSEGROUP_GROUP = 'COURSEGROUP_GROUP', + DELETED_ELEMENT = 'DELETED_ELEMENT', EXTERNAL_TOOL = 'EXTERNAL_TOOL', EXTERNAL_TOOL_ELEMENT = 'EXTERNAL_TOOL_ELEMENT', FILE = 'FILE', diff --git a/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts index e4e5e06f259..75a9611a087 100644 --- a/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts @@ -1,6 +1,6 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { TestApiClient } from '@shared/testing'; @@ -15,7 +15,7 @@ describe(`deletionExecution (api)`, () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts index 88a285d30b7..3a75d47f6b5 100644 --- a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts @@ -1,13 +1,13 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Request } from 'express'; -import { AuthGuard } from '@nestjs/passport'; -import { EntityManager } from '@mikro-orm/mongodb'; import { TestApiClient } from '@shared/testing'; -import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; -import { DeletionRequestBodyProps, DeletionRequestResponse } from '../dto'; -import { DeletionRequestEntity } from '../../../repo/entity'; +import { Request } from 'express'; import { DomainName } from '../../../domain/types'; +import { DeletionRequestEntity } from '../../../repo/entity'; +import { DeletionRequestBodyProps, DeletionRequestResponse } from '../dto'; const baseRouteName = '/deletionRequests'; @@ -61,7 +61,7 @@ describe(`deletionRequest create (api)`, () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts index 49cb2b78878..399df974353 100644 --- a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts @@ -1,12 +1,12 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Request } from 'express'; -import { AuthGuard } from '@nestjs/passport'; import { TestApiClient, cleanupCollections } from '@shared/testing'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; -import { deletionRequestEntityFactory } from '../../../repo/entity/testing'; +import { Request } from 'express'; import { DeletionRequestEntity } from '../../../repo/entity'; +import { deletionRequestEntityFactory } from '../../../repo/entity/testing'; const baseRouteName = '/deletionRequests'; @@ -20,7 +20,7 @@ describe(`deletionRequest delete (api)`, () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts index 5ea907ef47a..476340743d7 100644 --- a/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts @@ -1,10 +1,10 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Request } from 'express'; -import { AuthGuard } from '@nestjs/passport'; -import { EntityManager } from '@mikro-orm/mongodb'; import { TestApiClient, cleanupCollections } from '@shared/testing'; -import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { Request } from 'express'; import { deletionRequestEntityFactory } from '../../../repo/entity/testing'; import { DeletionRequestLogResponse } from '../dto'; @@ -20,7 +20,7 @@ describe(`deletionRequest find (api)`, () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts b/apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts index 143e69735dd..e1ac9e1a500 100644 --- a/apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts +++ b/apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts @@ -1,11 +1,11 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { Controller, HttpCode, Post, Query, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { AuthGuard } from '@nestjs/passport'; import { DeletionRequestUc } from '../uc'; import { DeletionExecutionParams } from './dto'; @ApiTags('DeletionExecutions') -@UseGuards(AuthGuard('api-key')) +@UseGuards(ApiKeyGuard) @Controller('deletionExecutions') export class DeletionExecutionsController { constructor(private readonly deletionRequestUc: DeletionRequestUc) {} diff --git a/apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts b/apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts index 159678f2042..f47cbc5d1f6 100644 --- a/apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts +++ b/apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts @@ -1,11 +1,11 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, HttpCode, Param, Post, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { AuthGuard } from '@nestjs/passport'; import { DeletionRequestUc } from '../uc'; -import { DeletionRequestLogResponse, DeletionRequestBodyProps, DeletionRequestResponse } from './dto'; +import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from './dto'; @ApiTags('DeletionRequests') -@UseGuards(AuthGuard('api-key')) +@UseGuards(ApiKeyGuard) @Controller('deletionRequests') export class DeletionRequestsController { constructor(private readonly deletionRequestUc: DeletionRequestUc) {} diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 31d39f78d3f..72e3054d07d 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -1,18 +1,17 @@ -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; -import { CqrsModule } from '@nestjs/cqrs'; -import { AuthenticationModule } from '@modules/authentication'; -import { RocketChatUserModule } from '@modules/rocketchat-user'; +import { CalendarModule } from '@infra/calendar'; import { ClassModule } from '@modules/class'; +import { FilesModule } from '@modules/files'; import { NewsModule } from '@modules/news'; -import { TeamsModule } from '@modules/teams'; import { PseudonymModule } from '@modules/pseudonym'; -import { FilesModule } from '@modules/files'; -import { CalendarModule } from '@src/infra/calendar'; +import { RocketChatUserModule } from '@modules/rocketchat-user'; +import { TeamsModule } from '@modules/teams'; +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { LoggerModule } from '@src/core/logger'; import { DeletionModule } from '.'; -import { DeletionRequestUc } from './api/uc'; import { DeletionExecutionsController } from './api/controller/deletion-executions.controller'; import { DeletionRequestsController } from './api/controller/deletion-requests.controller'; +import { DeletionRequestUc } from './api/uc'; @Module({ imports: [ @@ -20,7 +19,6 @@ import { DeletionRequestsController } from './api/controller/deletion-requests.c CqrsModule, DeletionModule, LoggerModule, - AuthenticationModule, ClassModule, NewsModule, TeamsModule, diff --git a/apps/server/src/modules/deletion/deletion.module.ts b/apps/server/src/modules/deletion/deletion.module.ts index 7769f86ddf4..74dd1e60d4e 100644 --- a/apps/server/src/modules/deletion/deletion.module.ts +++ b/apps/server/src/modules/deletion/deletion.module.ts @@ -1,8 +1,8 @@ +import { XApiKeyConfig } from '@infra/auth-guard'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestService, DeletionLogService } from './domain/service'; -import { DeletionRequestRepo, DeletionLogRepo } from './repo'; -import { XApiKeyConfig } from '../authentication/config/x-api-key.config'; +import { DeletionLogService, DeletionRequestService } from './domain/service'; +import { DeletionLogRepo, DeletionRequestRepo } from './repo'; @Module({ providers: [ diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts index b0f89f20bc4..aa8f543cc1c 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts @@ -1,7 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; 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 f82ef6ef757..4e474e32abb 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 @@ -1,9 +1,9 @@ import { createMock } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -105,6 +105,8 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 11c10182495..6f2faca6123 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 @@ -2,12 +2,12 @@ import { createMock } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { EntityId } from '@shared/domain/types'; import { cleanupCollections, @@ -106,6 +106,8 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 4b550b516c5..6e5878d3ee2 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -124,6 +124,8 @@ describe('files-storage controller (API)', () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 42356d4cd54..4ff5a612076 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 @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -66,6 +66,8 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index ae1667f7b38..b901debc078 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -1,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { PreviewProducer } from '@infra/preview-generator'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -137,6 +137,8 @@ describe('File Controller (API) - preview', () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts index e4d8cbee799..0f8ed070423 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -65,6 +65,8 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 328987c33a5..1cd5d3bca0a 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 @@ -1,9 +1,9 @@ import { createMock } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -132,6 +132,8 @@ describe(`${baseRouteName} (api)`, () => { }) .overrideProvider(NodeClam) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 b64196c3fe6..95bb74a6d06 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 @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { BadRequestException, Body, @@ -46,7 +46,7 @@ import { } from './dto'; @ApiTags('file') -@Authenticate('jwt') +@JwtAuthentication() @Controller('file') export class FilesStorageController { constructor(private readonly filesStorageUC: FilesStorageUC) {} @@ -108,7 +108,7 @@ export class FilesStorageController { @Res({ passthrough: true }) response: Response, @Headers('Range') bytesRange?: string ): Promise { - const fileResponse = await this.filesStorageUC.download(currentUser.userId, params, bytesRange); + const fileResponse = await this.filesStorageUC.download(params, bytesRange); const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange); @@ -190,10 +190,9 @@ export class FilesStorageController { @Get('/list/:storageLocation/:storageLocationId/:parentType/:parentId') async list( @Param() params: FileRecordParams, - @CurrentUser() currentUser: ICurrentUser, @Query() pagination: PaginationParams ): Promise { - const [fileRecords, total] = await this.filesStorageUC.getFileRecordsOfParent(currentUser.userId, params); + const [fileRecords, total] = await this.filesStorageUC.getFileRecordsOfParent(params); const { skip, limit } = pagination; const response = FileRecordMapper.mapToFileRecordListResponse(fileRecords, total, skip, limit); @@ -214,10 +213,9 @@ export class FilesStorageController { @UseInterceptors(RequestLoggingInterceptor) async patchFilename( @Param() params: SingleFileParams, - @Body() renameFileParam: RenameFileParams, - @CurrentUser() currentUser: ICurrentUser + @Body() renameFileParam: RenameFileParams ): Promise { - const fileRecord = await this.filesStorageUC.patchFilename(currentUser.userId, params, renameFileParam); + const fileRecord = await this.filesStorageUC.patchFilename(params, renameFileParam); const response = FileRecordMapper.mapToFileRecordResponse(fileRecord); @@ -234,11 +232,8 @@ export class FilesStorageController { @ApiResponse({ status: 500, type: InternalServerErrorException }) @Delete('/delete/:storageLocation/:storageLocationId/:parentType/:parentId') @UseInterceptors(RequestLoggingInterceptor) - async deleteByParent( - @Param() params: FileRecordParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const [fileRecords, total] = await this.filesStorageUC.deleteFilesOfParent(currentUser.userId, params); + async deleteByParent(@Param() params: FileRecordParams): Promise { + const [fileRecords, total] = await this.filesStorageUC.deleteFilesOfParent(params); const response = FileRecordMapper.mapToFileRecordListResponse(fileRecords, total); return response; @@ -251,11 +246,8 @@ export class FilesStorageController { @ApiResponse({ status: 500, type: InternalServerErrorException }) @Delete('/delete/:fileRecordId') @UseInterceptors(RequestLoggingInterceptor) - async deleteFile( - @Param() params: SingleFileParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const fileRecord = await this.filesStorageUC.deleteOneFile(currentUser.userId, params); + async deleteFile(@Param() params: SingleFileParams): Promise { + const fileRecord = await this.filesStorageUC.deleteOneFile(params); const response = FileRecordMapper.mapToFileRecordResponse(fileRecord); @@ -267,11 +259,8 @@ export class FilesStorageController { @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @Post('/restore/:storageLocation/:storageLocationId/:parentType/:parentId') - async restore( - @Param() params: FileRecordParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const [fileRecords, total] = await this.filesStorageUC.restoreFilesOfParent(currentUser.userId, params); + async restore(@Param() params: FileRecordParams): Promise { + const [fileRecords, total] = await this.filesStorageUC.restoreFilesOfParent(params); const response = FileRecordMapper.mapToFileRecordListResponse(fileRecords, total); @@ -283,11 +272,8 @@ export class FilesStorageController { @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @Post('/restore/:fileRecordId') - async restoreFile( - @Param() params: SingleFileParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const fileRecord = await this.filesStorageUC.restoreOneFile(currentUser.userId, params); + async restoreFile(@Param() params: SingleFileParams): Promise { + const fileRecord = await this.filesStorageUC.restoreOneFile(params); const response = FileRecordMapper.mapToFileRecordResponse(fileRecord); diff --git a/apps/server/src/modules/files-storage/files-storage-api.module.ts b/apps/server/src/modules/files-storage/files-storage-api.module.ts index b6675ece9b1..1bd6407d345 100644 --- a/apps/server/src/modules/files-storage/files-storage-api.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-api.module.ts @@ -1,23 +1,23 @@ -import { AuthenticationModule } from '@modules/authentication'; -import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { AuthGuardModule } from '@infra/auth-guard'; +import { AuthorizationClientModule } from '@infra/authorization-client'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { FileSecurityController, FilesStorageConfigController, FilesStorageController } from './controller'; -import { config } from './files-storage.config'; +import { authorizationClientConfig, config } from './files-storage.config'; import { FilesStorageModule } from './files-storage.module'; import { FilesStorageUC } from './uc'; @Module({ imports: [ - AuthorizationReferenceModule, FilesStorageModule, - AuthenticationModule, + AuthorizationClientModule.register(authorizationClientConfig), CoreModule, HttpModule, ConfigModule.forRoot(createConfigModuleOptions(config)), + AuthGuardModule, ], controllers: [FilesStorageController, FilesStorageConfigController, FileSecurityController], providers: [FilesStorageUC], diff --git a/apps/server/src/modules/files-storage/files-storage-test.module.ts b/apps/server/src/modules/files-storage/files-storage-test.module.ts index 3cc524c18d1..8fc04306de4 100644 --- a/apps/server/src/modules/files-storage/files-storage-test.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-test.module.ts @@ -2,8 +2,6 @@ import { DynamicModule, Module } from '@nestjs/common'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; -import { AuthenticationModule } from '@modules/authentication'; -import { AuthorizationModule } from '@modules/authorization'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; @@ -14,8 +12,6 @@ const imports = [ FilesStorageApiModule, MongoMemoryDatabaseModule.forRoot({ entities: [...ALL_ENTITIES, FileRecord] }), RabbitMQWrapperTestModule, - AuthorizationModule, - AuthenticationModule, CoreModule, LoggerModule, ]; 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 985aa728ff4..722cc31b0c6 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -1,9 +1,10 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthorizationClientConfig } from '@infra/authorization-client'; import { S3Config } from '@infra/s3-client'; import { CoreModuleConfig } from '@src/core'; export const FILES_STORAGE_S3_CONNECTION = 'FILES_STORAGE_S3_CONNECTION'; -export interface FileStorageConfig extends CoreModuleConfig { +export interface FileStorageConfig extends CoreModuleConfig, AuthorizationClientConfig { MAX_FILE_SIZE: number; MAX_SECURITY_CHECK_FILE_SIZE: number; USE_STREAM_TO_ANTIVIRUS: boolean; @@ -14,10 +15,15 @@ export const defaultConfig = { INCOMING_REQUEST_TIMEOUT: Configuration.get('FILES_STORAGE__INCOMING_REQUEST_TIMEOUT') as number, }; +export const authorizationClientConfig: AuthorizationClientConfig = { + basePath: `${Configuration.get('API_HOST') as string}/v3/`, +}; + const fileStorageConfig: FileStorageConfig = { MAX_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, USE_STREAM_TO_ANTIVIRUS: Configuration.get('FILES_STORAGE__USE_STREAM_TO_ANTIVIRUS') as boolean, + ...authorizationClientConfig, ...defaultConfig, }; diff --git a/apps/server/src/modules/files-storage/files-storage.const.ts b/apps/server/src/modules/files-storage/files-storage.const.ts index 87240256afe..970f575b327 100644 --- a/apps/server/src/modules/files-storage/files-storage.const.ts +++ b/apps/server/src/modules/files-storage/files-storage.const.ts @@ -1,5 +1,5 @@ +import { AuthorizationContextBuilder } from '@infra/authorization-client'; import { Permission } from '@shared/domain/interface'; -import { AuthorizationContextBuilder } from '../authorization'; export enum FilesStorageInternalActions { downloadBySecurityToken = '/file-security/download/:token', 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 14a2a1f931a..370f79350c1 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 { AuthorizableReferenceType } from '@modules/authorization/domain'; +import { AuthorizationBodyParamsReferenceType } from '@infra/authorization-client'; import { NotImplementedException, StreamableFile } from '@nestjs/common'; import { plainToClass } from 'class-transformer'; import { @@ -12,20 +12,20 @@ import { FileRecord } from '../entity'; import { FileRecordParentType, GetFileResponse } from '../interface'; export class FilesStorageMapper { - private static authorizationEntityMap: Map = new Map([ - [FileRecordParentType.Task, AuthorizableReferenceType.Task], - [FileRecordParentType.Course, AuthorizableReferenceType.Course], - [FileRecordParentType.User, AuthorizableReferenceType.User], - [FileRecordParentType.School, AuthorizableReferenceType.School], - [FileRecordParentType.Lesson, AuthorizableReferenceType.Lesson], - [FileRecordParentType.Submission, AuthorizableReferenceType.Submission], - [FileRecordParentType.Grading, AuthorizableReferenceType.Submission], - [FileRecordParentType.BoardNode, AuthorizableReferenceType.BoardNode], - [FileRecordParentType.ExternalTool, AuthorizableReferenceType.ExternalTool], + private static authorizationEntityMap: Map = new Map([ + [FileRecordParentType.Task, AuthorizationBodyParamsReferenceType.TASKS], + [FileRecordParentType.Course, AuthorizationBodyParamsReferenceType.COURSES], + [FileRecordParentType.User, AuthorizationBodyParamsReferenceType.USERS], + [FileRecordParentType.School, AuthorizationBodyParamsReferenceType.SCHOOLS], + [FileRecordParentType.Lesson, AuthorizationBodyParamsReferenceType.LESSONS], + [FileRecordParentType.Submission, AuthorizationBodyParamsReferenceType.SUBMISSIONS], + [FileRecordParentType.Grading, AuthorizationBodyParamsReferenceType.SUBMISSIONS], + [FileRecordParentType.BoardNode, AuthorizationBodyParamsReferenceType.BOARDNODES], + [FileRecordParentType.ExternalTool, AuthorizationBodyParamsReferenceType.EXTERNAL_TOOLS], ]); - public static mapToAllowedAuthorizationEntityType(type: FileRecordParentType): AuthorizableReferenceType { - const res: AuthorizableReferenceType | undefined = this.authorizationEntityMap.get(type); + public static mapToAllowedAuthorizationEntityType(type: FileRecordParentType): AuthorizationBodyParamsReferenceType { + const res: AuthorizationBodyParamsReferenceType | undefined = this.authorizationEntityMap.get(type); if (!res) { throw new NotImplementedException(); 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 92d3ebd6e81..e3b623e11db 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -75,7 +75,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeEach(() => { jest.resetAllMocks(); @@ -108,8 +108,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -127,7 +127,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); }); @@ -149,7 +149,7 @@ describe('FilesStorageUC', () => { const fileResponse = CopyFileResponseBuilder.build(targetFile.id, sourceFile.id, targetFile.name); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copyFilesOfParent.mockResolvedValueOnce([[fileResponse], 1]); return { sourceParams, targetParams, userId, fileResponse }; @@ -160,9 +160,8 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyFilesOfParent(userId, sourceParams, targetParams); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenNthCalledWith( 1, - userId, sourceParams.parentType, sourceParams.parentId, FileStorageAuthorizationContext.create @@ -174,9 +173,8 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyFilesOfParent(userId, sourceParams, targetParams); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenNthCalledWith( 2, - userId, targetParams.target.parentType, targetParams.target.parentId, FileStorageAuthorizationContext.create @@ -206,7 +204,7 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error).mockResolvedValueOnce(); return { sourceParams, targetParams, userId, error }; }; @@ -225,7 +223,7 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockRejectedValueOnce(error); return { sourceParams, targetParams, userId, error }; }; @@ -244,7 +242,7 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationReferenceService.checkPermissionByReferences + authorizationClientAdapter.checkPermissionsByReference .mockRejectedValueOnce(error) .mockRejectedValueOnce(error); @@ -266,7 +264,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copyFilesOfParent.mockRejectedValueOnce(error); return { sourceParams, targetParams, userId, error }; @@ -306,7 +304,7 @@ describe('FilesStorageUC', () => { ); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copy.mockResolvedValueOnce([fileResponse]); return { singleFileParams, copyFileParams, userId, fileResponse, fileRecord }; @@ -325,9 +323,8 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyOneFile(userId, singleFileParams, copyFileParams); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenNthCalledWith( 1, - userId, fileRecord.parentType, fileRecord.parentId, FileStorageAuthorizationContext.create @@ -339,9 +336,8 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyOneFile(userId, singleFileParams, copyFileParams); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenNthCalledWith( 2, - userId, copyFileParams.target.parentType, copyFileParams.target.parentId, FileStorageAuthorizationContext.create @@ -372,7 +368,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error).mockResolvedValueOnce(); return { singleFileParams, copyFileParams, userId, fileRecord, error }; }; @@ -392,7 +388,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockRejectedValueOnce(error); return { singleFileParams, copyFileParams, userId, fileRecord, error }; }; @@ -412,7 +408,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationReferenceService.checkPermissionByReferences + authorizationClientAdapter.checkPermissionsByReference .mockRejectedValueOnce(error) .mockRejectedValueOnce(error); @@ -453,7 +449,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copy.mockRejectedValueOnce(error); return { singleFileParams, copyFileParams, userId, fileRecord, error }; 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 87fb8e86cbb..8213b6132a8 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -62,7 +62,7 @@ describe('FilesStorageUC delete methods', () => { let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; let previewService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -91,8 +91,8 @@ describe('FilesStorageUC delete methods', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -110,7 +110,7 @@ describe('FilesStorageUC delete methods', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); previewService = module.get(PreviewService); }); @@ -130,25 +130,24 @@ describe('FilesStorageUC delete methods', () => { describe('deleteFilesOfParent is called', () => { describe('WHEN user is authorized and service deletes successful', () => { const setup = () => { - const { params, userId, fileRecords } = buildFileRecordsWithParams(); + const { params, fileRecords } = buildFileRecordsWithParams(); const { requestParams } = createParams(); const fileRecord = fileRecords[0]; const mockedResult = [[fileRecord], 0] as Counted; - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce(mockedResult); - return { params, userId, mockedResult, requestParams, fileRecord }; + return { params, mockedResult, requestParams, fileRecord }; }; it('should call authorizationService.checkPermissionByReferences', async () => { - const { userId, requestParams } = setup(); + const { requestParams } = setup(); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(requestParams.parentType); - await filesStorageUC.deleteFilesOfParent(userId, requestParams); + await filesStorageUC.deleteFilesOfParent(requestParams); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( allowedType, requestParams.parentId, FileStorageAuthorizationContext.delete @@ -156,25 +155,25 @@ describe('FilesStorageUC delete methods', () => { }); it('should call service with correct params', async () => { - const { requestParams, userId, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - await filesStorageUC.deleteFilesOfParent(userId, requestParams); + await filesStorageUC.deleteFilesOfParent(requestParams); expect(filesStorageService.deleteFilesOfParent).toHaveBeenCalledWith([fileRecord]); }); it('should call deletePreviews', async () => { - const { requestParams, userId, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - await filesStorageUC.deleteFilesOfParent(userId, requestParams); + await filesStorageUC.deleteFilesOfParent(requestParams); expect(previewService.deletePreviews).toHaveBeenCalledWith([fileRecord]); }); it('should return results of service', async () => { - const { params, userId, mockedResult } = setup(); + const { params, mockedResult } = setup(); - const result = await filesStorageUC.deleteFilesOfParent(userId, params); + const result = await filesStorageUC.deleteFilesOfParent(params); expect(result).toEqual(mockedResult); }); @@ -182,19 +181,17 @@ describe('FilesStorageUC delete methods', () => { describe('WHEN user is not authorized', () => { const setup = () => { - const { requestParams, userId } = createParams(); + const { requestParams } = createParams(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); - return { requestParams, userId }; + return { requestParams }; }; it('should throw forbidden error', async () => { - const { requestParams, userId } = setup(); + const { requestParams } = setup(); - await expect(filesStorageUC.deleteFilesOfParent(userId, requestParams)).rejects.toThrow( - new ForbiddenException() - ); + await expect(filesStorageUC.deleteFilesOfParent(requestParams)).rejects.toThrow(new ForbiddenException()); expect(filesStorageService.deleteFilesOfParent).toHaveBeenCalledTimes(0); }); @@ -204,11 +201,11 @@ describe('FilesStorageUC delete methods', () => { const setup = () => { const { fileRecords } = buildFileRecordsWithParams(); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); const { requestParams, userId } = createParams(); const error = new Error('test'); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); filesStorageService.deleteFilesOfParent.mockRejectedValueOnce(error); @@ -216,9 +213,9 @@ describe('FilesStorageUC delete methods', () => { }; it('should return error of service', async () => { - const { requestParams, userId, error } = setup(); + const { requestParams, error } = setup(); - await expect(filesStorageUC.deleteFilesOfParent(userId, requestParams)).rejects.toThrow(error); + await expect(filesStorageUC.deleteFilesOfParent(requestParams)).rejects.toThrow(error); }); }); }); @@ -226,26 +223,25 @@ describe('FilesStorageUC delete methods', () => { describe('deleteOneFile is called', () => { describe('WHEN user is authorized, file is found and delete was successful ', () => { const setup = () => { - const { fileRecords, userId } = buildFileRecordsWithParams(); + const { fileRecords } = buildFileRecordsWithParams(); const fileRecord = fileRecords[0]; const requestParams = { fileRecordId: fileRecord.id, parentType: fileRecord.parentType }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.delete.mockResolvedValueOnce(); - return { requestParams, userId, fileRecord }; + return { requestParams, fileRecord }; }; it('should call authorizationService.checkPermissionByReferences', async () => { - const { requestParams, userId, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - await filesStorageUC.deleteOneFile(userId, requestParams); + await filesStorageUC.deleteOneFile(requestParams); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(requestParams.parentType); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( allowedType, fileRecord.parentId, FileStorageAuthorizationContext.delete @@ -253,41 +249,41 @@ describe('FilesStorageUC delete methods', () => { }); it('should call getFile once', async () => { - const { userId, requestParams } = setup(); + const { requestParams } = setup(); - await filesStorageUC.deleteOneFile(userId, requestParams); + await filesStorageUC.deleteOneFile(requestParams); expect(filesStorageService.getFileRecord).toHaveBeenCalledTimes(1); }); it('should call getFile with correctly params', async () => { - const { userId, requestParams } = setup(); + const { requestParams } = setup(); - await filesStorageUC.deleteOneFile(userId, requestParams); + await filesStorageUC.deleteOneFile(requestParams); expect(filesStorageService.getFileRecord).toHaveBeenCalledWith(requestParams); }); it('should call delete with correct params', async () => { - const { userId, requestParams, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - await filesStorageUC.deleteOneFile(userId, requestParams); + await filesStorageUC.deleteOneFile(requestParams); expect(filesStorageService.delete).toHaveBeenCalledWith([fileRecord]); }); it('should call deletePreviews', async () => { - const { userId, requestParams, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - await filesStorageUC.deleteOneFile(userId, requestParams); + await filesStorageUC.deleteOneFile(requestParams); expect(previewService.deletePreviews).toHaveBeenCalledWith([fileRecord]); }); it('should return fileRecord', async () => { - const { userId, requestParams, fileRecord } = setup(); + const { requestParams, fileRecord } = setup(); - const result = await filesStorageUC.deleteOneFile(userId, requestParams); + const result = await filesStorageUC.deleteOneFile(requestParams); expect(result).toEqual(fileRecord); }); @@ -305,50 +301,50 @@ describe('FilesStorageUC delete methods', () => { }; it('should throw error if entity not found', async () => { - const { userId, requestParams, error } = setup(); + const { requestParams, error } = setup(); - await expect(filesStorageUC.deleteOneFile(userId, requestParams)).rejects.toThrow(error); + await expect(filesStorageUC.deleteOneFile(requestParams)).rejects.toThrow(error); }); }); describe('WHEN user is not authorized', () => { const setup = () => { - const { fileRecords, userId } = buildFileRecordsWithParams(); + const { fileRecords } = buildFileRecordsWithParams(); const fileRecord = fileRecords[0]; const requestParams = { fileRecordId: fileRecord.id, parentType: fileRecord.parentType }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); - return { requestParams, userId }; + return { requestParams }; }; it('should throw forbidden exception', async () => { - const { requestParams, userId } = setup(); + const { requestParams } = setup(); - await expect(filesStorageUC.deleteOneFile(userId, requestParams)).rejects.toThrow(new ForbiddenException()); + await expect(filesStorageUC.deleteOneFile(requestParams)).rejects.toThrow(new ForbiddenException()); expect(filesStorageService.delete).toHaveBeenCalledTimes(0); }); }); describe('WHEN delete throws error', () => { const setup = () => { - const { fileRecords, userId } = buildFileRecordsWithParams(); + const { fileRecords } = buildFileRecordsWithParams(); const fileRecord = fileRecords[0]; const requestParams = { fileRecordId: fileRecord.id }; const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.delete.mockRejectedValueOnce(error); - return { requestParams, userId, error }; + return { requestParams, error }; }; it('should throw error', async () => { - const { userId, requestParams, error } = setup(); + const { requestParams, error } = setup(); - await expect(filesStorageUC.deleteOneFile(userId, requestParams)).rejects.toThrow(error); + await expect(filesStorageUC.deleteOneFile(requestParams)).rejects.toThrow(error); }); }); }); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index 22f0d437801..eeede8b551e 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -45,7 +45,7 @@ describe('FilesStorageUC', () => { let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; let previewService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -78,8 +78,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -93,7 +93,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); previewService = module.get(PreviewService); }); @@ -148,8 +148,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( allowedType, fileRecord.parentId, FileStorageAuthorizationContext.read @@ -195,7 +194,7 @@ describe('FilesStorageUC', () => { filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); const error = new ForbiddenException(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error); return { fileDownloadParams, userId, fileRecord, previewParams, 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 7f7795d8c90..c2597e11b6a 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -29,14 +29,14 @@ const buildFileRecordWithParams = () => { fileRecordId: fileRecord.id, }; - return { params, fileRecord, userId }; + return { params, fileRecord }; }; describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -65,8 +65,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -84,7 +84,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); }); @@ -103,22 +103,22 @@ describe('FilesStorageUC', () => { describe('download is called', () => { describe('WHEN file is found, user is authorized and file is successfully downloaded', () => { const setup = () => { - const { fileRecord, params, userId } = buildFileRecordWithParams(); + const { fileRecord, params } = buildFileRecordWithParams(); const fileDownloadParams = { ...params, fileName: fileRecord.name }; const fileResponse = createMock(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValue(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValue(); filesStorageService.download.mockResolvedValueOnce(fileResponse); - return { fileDownloadParams, userId, fileRecord, fileResponse }; + return { fileDownloadParams, fileRecord, fileResponse }; }; it('should call getFile with correct params', async () => { - const { fileDownloadParams, userId } = setup(); + const { fileDownloadParams } = setup(); - await filesStorageUC.download(userId, fileDownloadParams); + await filesStorageUC.download(fileDownloadParams); expect(filesStorageService.getFileRecord).toHaveBeenCalledWith({ fileRecordId: fileDownloadParams.fileRecordId, @@ -126,13 +126,12 @@ describe('FilesStorageUC', () => { }); it('should call checkPermissionByReferences with correct params', async () => { - const { fileDownloadParams, userId, fileRecord } = setup(); + const { fileDownloadParams, fileRecord } = setup(); - await filesStorageUC.download(userId, fileDownloadParams); + await filesStorageUC.download(fileDownloadParams); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( allowedType, fileRecord.parentId, FileStorageAuthorizationContext.read @@ -140,17 +139,17 @@ describe('FilesStorageUC', () => { }); it('should call donwload with correct params', async () => { - const { fileDownloadParams, userId, fileRecord } = setup(); + const { fileDownloadParams, fileRecord } = setup(); - await filesStorageUC.download(userId, fileDownloadParams); + await filesStorageUC.download(fileDownloadParams); expect(filesStorageService.download).toHaveBeenCalledWith(fileRecord, fileDownloadParams, undefined); }); it('should return correct result', async () => { - const { fileDownloadParams, userId, fileResponse } = setup(); + const { fileDownloadParams, fileResponse } = setup(); - const result = await filesStorageUC.download(userId, fileDownloadParams); + const result = await filesStorageUC.download(fileDownloadParams); expect(result).toEqual(fileResponse); }); @@ -158,58 +157,58 @@ describe('FilesStorageUC', () => { describe('WHEN getFile throws error', () => { const setup = () => { - const { fileRecord, params, userId } = buildFileRecordWithParams(); + const { fileRecord, params } = buildFileRecordWithParams(); const fileDownloadParams = { ...params, fileName: fileRecord.name }; const error = new Error('test'); filesStorageService.getFileRecord.mockRejectedValueOnce(error); - return { fileDownloadParams, userId, error }; + return { fileDownloadParams, error }; }; it('should pass error', async () => { - const { fileDownloadParams, error, userId } = setup(); + const { fileDownloadParams, error } = setup(); - await expect(filesStorageUC.download(userId, fileDownloadParams)).rejects.toThrowError(error); + await expect(filesStorageUC.download(fileDownloadParams)).rejects.toThrowError(error); }); }); describe('WHEN user is not authorized', () => { const setup = () => { - const { fileRecord, params, userId } = buildFileRecordWithParams(); + const { fileRecord, params } = buildFileRecordWithParams(); const fileDownloadParams = { ...params, fileName: fileRecord.name }; const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error); - return { fileDownloadParams, userId, fileRecord }; + return { fileDownloadParams, fileRecord }; }; it('should throw Error', async () => { - const { fileDownloadParams, userId } = setup(); + const { fileDownloadParams } = setup(); - await expect(filesStorageUC.download(userId, fileDownloadParams)).rejects.toThrow(); + await expect(filesStorageUC.download(fileDownloadParams)).rejects.toThrow(); }); }); describe('WHEN service throws error', () => { const setup = () => { - const { fileRecord, params, userId } = buildFileRecordWithParams(); + const { fileRecord, params } = buildFileRecordWithParams(); const fileDownloadParams = { ...params, fileName: fileRecord.name }; const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValue(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValue(); filesStorageService.download.mockRejectedValueOnce(error); - return { fileDownloadParams, userId, error }; + return { fileDownloadParams, error }; }; it('should pass error', async () => { - const { fileDownloadParams, userId, error } = setup(); + const { fileDownloadParams, error } = setup(); - await expect(filesStorageUC.download(userId, fileDownloadParams)).rejects.toThrow(error); + await expect(filesStorageUC.download(fileDownloadParams)).rejects.toThrow(error); }); }); }); 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 a2a6e499844..6305938c159 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { fileRecordFactory, setupEntities } from '@shared/testing'; @@ -41,7 +41,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -70,8 +70,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -89,7 +89,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); }); @@ -108,22 +108,20 @@ describe('FilesStorageUC', () => { describe('getFileRecordsOfParent is called', () => { describe('when user is authorised and valid files exist', () => { const setup = () => { - const userId = new ObjectId().toHexString(); const { fileRecords, params } = buildFileRecordsWithParams(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); - return { userId, params, fileRecords }; + return { params, fileRecords }; }; it('should call authorisation with right parameters', async () => { - const { userId, params } = setup(); + const { params } = setup(); - await filesStorageUC.getFileRecordsOfParent(userId, params); + await filesStorageUC.getFileRecordsOfParent(params); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( params.parentType, params.parentId, FileStorageAuthorizationContext.read @@ -131,17 +129,17 @@ describe('FilesStorageUC', () => { }); it('should call service method getFilesOfParent with right parameters', async () => { - const { userId, params } = setup(); + const { params } = setup(); - await filesStorageUC.getFileRecordsOfParent(userId, params); + await filesStorageUC.getFileRecordsOfParent(params); expect(filesStorageService.getFileRecordsOfParent).toHaveBeenCalledWith(params.parentId); }); it('should return counted file records', async () => { - const { userId, params, fileRecords } = setup(); + const { params, fileRecords } = setup(); - const result = await filesStorageUC.getFileRecordsOfParent(userId, params); + const result = await filesStorageUC.getFileRecordsOfParent(params); expect(result).toEqual([fileRecords, fileRecords.length]); }); @@ -149,38 +147,36 @@ describe('FilesStorageUC', () => { describe('when user is not authorised', () => { const setup = () => { - const userId = new ObjectId().toHexString(); const { fileRecords, params } = buildFileRecordsWithParams(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new Error('Bla')); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new Error('Bla')); - return { userId, params, fileRecords }; + return { params, fileRecords }; }; it('should pass the error', async () => { - const { userId, params } = setup(); + const { params } = setup(); - await expect(filesStorageUC.getFileRecordsOfParent(userId, params)).rejects.toThrowError(new Error('Bla')); + await expect(filesStorageUC.getFileRecordsOfParent(params)).rejects.toThrowError(new Error('Bla')); }); }); describe('when user is authorised but no files exist', () => { const setup = () => { - const userId = new ObjectId().toHexString(); const { params } = buildFileRecordsWithParams(); const fileRecords = []; filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); - return { userId, params, fileRecords }; + return { params, fileRecords }; }; it('should return empty counted file records', async () => { - const { userId, params } = setup(); + const { params } = setup(); - const result = await filesStorageUC.getFileRecordsOfParent(userId, params); + const result = await filesStorageUC.getFileRecordsOfParent(params); expect(result).toEqual([[], 0]); }); 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 4e1f074f077..d463f4b14a6 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -58,7 +58,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -87,8 +87,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -106,7 +106,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); }); @@ -125,22 +125,21 @@ describe('FilesStorageUC', () => { describe('restoreFilesOfParent is called', () => { describe('WHEN user is authorised and files to restore exist', () => { const setup = () => { - const { params, userId, fileRecords } = buildFileRecordsWithParams(); + const { params, fileRecords } = buildFileRecordsWithParams(); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.restoreFilesOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - return { params, userId, fileRecords }; + return { params, fileRecords }; }; it('should call authorisation with right parameters', async () => { - const { params, userId } = setup(); + const { params } = setup(); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(params.parentType); - await filesStorageUC.restoreFilesOfParent(userId, params); + await filesStorageUC.restoreFilesOfParent(params); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( allowedType, params.parentId, FileStorageAuthorizationContext.create @@ -148,17 +147,17 @@ describe('FilesStorageUC', () => { }); it('should call filesStorageService with right parameters', async () => { - const { params, userId } = setup(); + const { params } = setup(); - await filesStorageUC.restoreFilesOfParent(userId, params); + await filesStorageUC.restoreFilesOfParent(params); expect(filesStorageService.restoreFilesOfParent).toHaveBeenCalledWith(params); }); it('should return counted result', async () => { - const { params, userId, fileRecords } = setup(); + const { params, fileRecords } = setup(); - const result = await filesStorageUC.restoreFilesOfParent(userId, params); + const result = await filesStorageUC.restoreFilesOfParent(params); expect(result).toEqual([fileRecords, 3]); }); @@ -166,33 +165,33 @@ describe('FilesStorageUC', () => { describe('WHEN user is not authorised ', () => { const setup = () => { - const { params, userId } = buildFileRecordsWithParams(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + const { params } = buildFileRecordsWithParams(); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); - return { params, userId }; + return { params }; }; it('should throw forbidden error', async () => { - const { params, userId } = setup(); - await expect(filesStorageUC.restoreFilesOfParent(userId, params)).rejects.toThrow(new ForbiddenException()); + const { params } = setup(); + await expect(filesStorageUC.restoreFilesOfParent(params)).rejects.toThrow(new ForbiddenException()); expect(filesStorageService.getFileRecordsOfParent).toHaveBeenCalledTimes(0); }); }); describe('WHEN service throws an error', () => { const setup = () => { - const { params, userId } = buildFileRecordsWithParams(); + const { params } = buildFileRecordsWithParams(); const error = new Error('test'); filesStorageService.restoreFilesOfParent.mockRejectedValueOnce(error); - return { params, userId, error }; + return { params, error }; }; it('should return error of service', async () => { - const { params, userId, error } = setup(); + const { params, error } = setup(); - await expect(filesStorageUC.restoreFilesOfParent(userId, params)).rejects.toThrow(error); + await expect(filesStorageUC.restoreFilesOfParent(params)).rejects.toThrow(error); }); }); }); @@ -200,31 +199,30 @@ describe('FilesStorageUC', () => { describe('restoreOneFile is called', () => { describe('WHEN user is authorised', () => { const setup = () => { - const { params, userId, fileRecord } = buildFileRecordWithParams(); + const { params, fileRecord } = buildFileRecordWithParams(); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.restore.mockResolvedValueOnce(); - return { params, userId, fileRecord }; + return { params, fileRecord }; }; it('should call filesStorageService.getMarkForDeletedFile with right parameters', async () => { - const { params, userId } = setup(); + const { params } = setup(); - await filesStorageUC.restoreOneFile(userId, params); + await filesStorageUC.restoreOneFile(params); expect(filesStorageService.getFileRecordMarkedForDelete).toHaveBeenCalledWith(params); }); it('should call authorisation with right parameters', async () => { - const { params, userId, fileRecord } = setup(); + const { params, fileRecord } = setup(); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); - await filesStorageUC.restoreOneFile(userId, params); + await filesStorageUC.restoreOneFile(params); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( allowedType, fileRecord.parentId, FileStorageAuthorizationContext.create @@ -232,17 +230,17 @@ describe('FilesStorageUC', () => { }); it('should call filesStorageService with right parameters', async () => { - const { params, userId, fileRecord } = setup(); + const { params, fileRecord } = setup(); - await filesStorageUC.restoreOneFile(userId, params); + await filesStorageUC.restoreOneFile(params); expect(filesStorageService.restore).toHaveBeenCalledWith([fileRecord]); }); it('should return counted result', async () => { - const { params, userId, fileRecord } = setup(); + const { params, fileRecord } = setup(); - const result = await filesStorageUC.restoreOneFile(userId, params); + const result = await filesStorageUC.restoreOneFile(params); expect(result).toEqual(fileRecord); }); @@ -250,18 +248,18 @@ describe('FilesStorageUC', () => { describe('WHEN user is not authorised ', () => { const setup = () => { - const { params, userId, fileRecord } = buildFileRecordWithParams(); + const { params, fileRecord } = buildFileRecordWithParams(); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); - return { params, userId }; + return { params }; }; it('should throw forbidden error', async () => { - const { params, userId } = setup(); + const { params } = setup(); - await expect(filesStorageUC.restoreOneFile(userId, params)).rejects.toThrow(new ForbiddenException()); + await expect(filesStorageUC.restoreOneFile(params)).rejects.toThrow(new ForbiddenException()); expect(filesStorageService.restore).toHaveBeenCalledTimes(0); }); @@ -269,37 +267,37 @@ describe('FilesStorageUC', () => { describe('WHEN service getMarkForDeletedFile throws an error', () => { const setup = () => { - const { params, userId } = buildFileRecordWithParams(); + const { params } = buildFileRecordWithParams(); const error = new Error('test'); filesStorageService.getFileRecordMarkedForDelete.mockRejectedValueOnce(error); - return { params, userId, error }; + return { params, error }; }; it('should return error of service', async () => { - const { params, userId, error } = setup(); + const { params, error } = setup(); - await expect(filesStorageUC.restoreOneFile(userId, params)).rejects.toThrow(error); + await expect(filesStorageUC.restoreOneFile(params)).rejects.toThrow(error); }); }); describe('WHEN service restore throws an error', () => { const setup = () => { - const { params, userId, fileRecord } = buildFileRecordWithParams(); + const { params, fileRecord } = buildFileRecordWithParams(); const error = new Error('test'); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.restore.mockRejectedValueOnce(error); - return { params, userId, error }; + return { params, error }; }; it('should return error of service', async () => { - const { params, userId, error } = setup(); + const { params, error } = setup(); - await expect(filesStorageUC.restoreOneFile(userId, params)).rejects.toThrow(error); + await expect(filesStorageUC.restoreOneFile(params)).rejects.toThrow(error); }); }); }); 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 cdb579c3d2f..db8d340e11e 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 @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { fileRecordFactory, setupEntities } from '@shared/testing'; @@ -33,7 +33,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -62,8 +62,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -81,7 +81,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); filesStorageService = module.get(FilesStorageService); }); @@ -142,31 +142,29 @@ describe('FilesStorageUC', () => { describe('patchFilename is called', () => { describe('WHEN user is authorised and single file exists', () => { const setup = () => { - const userId = new ObjectId().toHexString(); const { fileRecord, params } = buildFileRecordWithParams(); const data: RenameFileParams = { fileName: 'test_new_name.txt' }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); filesStorageService.patchFilename.mockResolvedValueOnce(fileRecord); - return { userId, params, fileRecord, data }; + return { params, fileRecord, data }; }; it('should call service method getFile with right parameters', async () => { - const { userId, params, data } = setup(); - await filesStorageUC.patchFilename(userId, params, data); + const { params, data } = setup(); + await filesStorageUC.patchFilename(params, data); expect(filesStorageService.getFileRecord).toHaveBeenCalledWith(params); }); it('should call authorisation with right parameters', async () => { - const { userId, params, data, fileRecord } = setup(); + const { params, data, fileRecord } = setup(); - await filesStorageUC.patchFilename(userId, params, data); + await filesStorageUC.patchFilename(params, data); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( fileRecord.parentType, fileRecord.parentId, FileStorageAuthorizationContext.update @@ -174,17 +172,17 @@ describe('FilesStorageUC', () => { }); it('should call service method patchFilename with right parameters', async () => { - const { userId, params, fileRecord, data } = setup(); + const { params, fileRecord, data } = setup(); - await filesStorageUC.patchFilename(userId, params, data); + await filesStorageUC.patchFilename(params, data); expect(filesStorageService.patchFilename).toHaveBeenCalledWith(fileRecord, data); }); it('should return modified fileRecord', async () => { - const { userId, params, fileRecord, data } = setup(); + const { params, fileRecord, data } = setup(); - const result = await filesStorageUC.patchFilename(userId, params, data); + const result = await filesStorageUC.patchFilename(params, data); expect(result).toEqual(fileRecord); }); 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 754d345d409..cf395fa9752 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 @@ -1,14 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { - Action, - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationReferenceService, -} from '@modules/authorization/domain'; +import { Action, AuthorizableReferenceType, AuthorizationContextBuilder } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -81,7 +77,7 @@ describe('FilesStorageUC upload methods', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; let httpService: DeepMocked; beforeAll(async () => { @@ -111,8 +107,8 @@ describe('FilesStorageUC upload methods', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: HttpService, @@ -130,7 +126,7 @@ describe('FilesStorageUC upload methods', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); httpService = module.get(HttpService); filesStorageService = module.get(FilesStorageService); }); @@ -188,8 +184,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.uploadFromUrl(userId, uploadFromUrlParams); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( uploadFromUrlParams.parentType, uploadFromUrlParams.parentId, { action: Action.write, requiredPermissions: [Permission.FILESTORAGE_CREATE] } @@ -201,8 +196,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.uploadFromUrl(userId, uploadFromUrlParams); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( AuthorizableReferenceType.School, uploadFromUrlParams.storageLocationId, AuthorizationContextBuilder.write([]) @@ -248,7 +242,7 @@ describe('FilesStorageUC upload methods', () => { const setup = () => { const { userId, uploadFromUrlParams } = createUploadFromUrlParams(); const error = new Error('test'); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error); return { uploadFromUrlParams, userId, error }; }; @@ -338,8 +332,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.upload(userId, params, request); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(params.parentType); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( allowedType, params.parentId, FileStorageAuthorizationContext.create @@ -351,8 +344,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.upload(userId, params, request); - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toHaveBeenCalledWith( AuthorizableReferenceType.Instance, params.storageLocationId, AuthorizationContextBuilder.write([Permission.INSTANCE_VIEW]) @@ -422,7 +414,7 @@ describe('FilesStorageUC upload methods', () => { const request = createRequest(); const error = new ForbiddenException(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(error); return { params, userId, request, error }; }; 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 f4ed46d6d28..ac80e3d45a8 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 @@ -1,6 +1,10 @@ +import { + AuthorizationBodyParamsReferenceType, + AuthorizationClientAdapter, + AuthorizationContextBuilder, + AuthorizationContextParams, +} from '@infra/authorization-client'; import { EntityManager, RequestContext } from '@mikro-orm/core'; -import { AuthorizableReferenceType, AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { Injectable, NotFoundException } from '@nestjs/common'; import { Permission } from '@shared/domain/interface'; @@ -36,7 +40,7 @@ import { FilesStorageService, PreviewService } from '../service'; export class FilesStorageUC { constructor( private readonly logger: LegacyLogger, - private readonly authorizationReferenceService: AuthorizationReferenceService, + private readonly authorizationClientAdapter: AuthorizationClientAdapter, private readonly httpService: HttpService, private readonly filesStorageService: FilesStorageService, private readonly previewService: PreviewService, @@ -48,14 +52,13 @@ export class FilesStorageUC { } private async checkPermission( - userId: EntityId, parentType: FileRecordParentType, parentId: EntityId, - context: AuthorizationContext + context: AuthorizationContextParams ): Promise { - const allowedType: AuthorizableReferenceType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(parentType); + const referenceType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(parentType); - await this.authorizationReferenceService.checkPermissionByReferences(userId, allowedType, parentId, context); + await this.authorizationClientAdapter.checkPermissionsByReference(referenceType, parentId, context); } public getPublicConfig(): FilesStorageConfigResponse { @@ -68,33 +71,27 @@ export class FilesStorageUC { // upload public async upload(userId: EntityId, params: FileRecordParams, req: Request): Promise { - await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.create); + await this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.create); - await this.checkStorageLocation(userId, params.storageLocation, params.storageLocationId); + await this.checkStorageLocation(params.storageLocation, params.storageLocationId); const fileRecord = await this.uploadFileWithBusboy(userId, params, req); return fileRecord; } - private async checkStorageLocation( - userId: EntityId, - storageLocation: StorageLocation, - storageLocationId: EntityId - ): Promise { + private async checkStorageLocation(storageLocation: StorageLocation, storageLocationId: EntityId): Promise { if (storageLocation === StorageLocation.INSTANCE) { - await this.authorizationReferenceService.checkPermissionByReferences( - userId, - AuthorizableReferenceType.Instance, + await this.authorizationClientAdapter.checkPermissionsByReference( + AuthorizationBodyParamsReferenceType.INSTANCES, storageLocationId, AuthorizationContextBuilder.write([Permission.INSTANCE_VIEW]) ); } if (storageLocation === StorageLocation.SCHOOL) { - await this.authorizationReferenceService.checkPermissionByReferences( - userId, - AuthorizableReferenceType.School, + await this.authorizationClientAdapter.checkPermissionsByReference( + AuthorizationBodyParamsReferenceType.SCHOOLS, storageLocationId, AuthorizationContextBuilder.write([]) ); @@ -132,9 +129,9 @@ export class FilesStorageUC { } public async uploadFromUrl(userId: EntityId, params: FileRecordParams & FileUrlParams) { - await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.create); + await this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.create); - await this.checkStorageLocation(userId, params.storageLocation, params.storageLocationId); + await this.checkStorageLocation(params.storageLocation, params.storageLocationId); const response = await this.getResponse(params); @@ -175,12 +172,12 @@ export class FilesStorageUC { } // download - public async download(userId: EntityId, params: DownloadFileParams, bytesRange?: string): Promise { + public async download(params: DownloadFileParams, 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); + await this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.read); return this.filesStorageService.download(fileRecord, params, bytesRange); } @@ -202,7 +199,7 @@ export class FilesStorageUC { const fileRecord = await this.filesStorageService.getFileRecord(singleFileParams); const { parentType, parentId } = fileRecord.getParentInfo(); - await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.read); + await this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.read); this.filesStorageService.checkFileName(fileRecord, params); @@ -212,8 +209,8 @@ export class FilesStorageUC { } // delete - public async deleteFilesOfParent(userId: EntityId, params: FileRecordParams): Promise> { - await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.delete); + public async deleteFilesOfParent(params: FileRecordParams): Promise> { + await this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.delete); const [fileRecords, count] = await this.filesStorageService.getFileRecordsOfParent(params.parentId); await this.previewService.deletePreviews(fileRecords); await this.filesStorageService.deleteFilesOfParent(fileRecords); @@ -221,11 +218,11 @@ export class FilesStorageUC { return [fileRecords, count]; } - public async deleteOneFile(userId: EntityId, params: SingleFileParams): Promise { + public async deleteOneFile(params: SingleFileParams): Promise { const fileRecord = await this.filesStorageService.getFileRecord(params); const { parentType, parentId } = fileRecord.getParentInfo(); - await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.delete); + await this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.delete); await this.previewService.deletePreviews([fileRecord]); await this.filesStorageService.delete([fileRecord]); @@ -233,18 +230,18 @@ export class FilesStorageUC { } // restore - public async restoreFilesOfParent(userId: EntityId, params: FileRecordParams): Promise> { - await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.create); + public async restoreFilesOfParent(params: FileRecordParams): Promise> { + await this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.create); const [fileRecords, count] = await this.filesStorageService.restoreFilesOfParent(params); return [fileRecords, count]; } - public async restoreOneFile(userId: EntityId, params: SingleFileParams): Promise { + public async restoreOneFile(params: SingleFileParams): Promise { const fileRecord = await this.filesStorageService.getFileRecordMarkedForDelete(params); const { parentType, parentId } = fileRecord.getParentInfo(); - await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.create); + await this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.create); await this.filesStorageService.restore([fileRecord]); return fileRecord; @@ -257,9 +254,8 @@ export class FilesStorageUC { copyFilesParams: CopyFilesOfParentParams ): Promise> { await Promise.all([ - this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.create), + this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.create), this.checkPermission( - userId, copyFilesParams.target.parentType, copyFilesParams.target.parentId, FileStorageAuthorizationContext.create @@ -280,9 +276,8 @@ export class FilesStorageUC { const { parentType, parentId } = fileRecord.getParentInfo(); await Promise.all([ - this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.create), + this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.create), this.checkPermission( - userId, copyFileParams.target.parentType, copyFileParams.target.parentId, FileStorageAuthorizationContext.create @@ -295,11 +290,11 @@ export class FilesStorageUC { } // update - public async patchFilename(userId: EntityId, params: SingleFileParams, data: RenameFileParams): Promise { + public async patchFilename(params: SingleFileParams, data: RenameFileParams): Promise { const fileRecord = await this.filesStorageService.getFileRecord(params); const { parentType, parentId } = fileRecord.getParentInfo(); - await this.checkPermission(userId, parentType, parentId, FileStorageAuthorizationContext.update); + await this.checkPermission(parentType, parentId, FileStorageAuthorizationContext.update); const modifiedFileRecord = await this.filesStorageService.patchFilename(fileRecord, data); @@ -312,8 +307,8 @@ export class FilesStorageUC { } // get - public async getFileRecordsOfParent(userId: EntityId, params: FileRecordParams): Promise> { - await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.read); + public async getFileRecordsOfParent(params: FileRecordParams): Promise> { + await this.checkPermission(params.parentType, params.parentId, FileStorageAuthorizationContext.read); const countedFileRecords = await this.filesStorageService.getFileRecordsOfParent(params.parentId); 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 1c9921dbff5..62f4955edab 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 @@ -1,9 +1,9 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { JwtAuthGuard } from '@infra/auth-guard'; +import { S3ClientAdapter } from '@infra/s3-client'; import { INestApplication, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { S3ClientAdapter } from '@infra/s3-client'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { Readable } from 'stream'; import request from 'supertest'; import { FwuLearningContentsTestModule } from '../../fwu-learning-contents-test.module'; diff --git a/apps/server/src/modules/fwu-learning-contents/controller/fwu-learning-contents.controller.ts b/apps/server/src/modules/fwu-learning-contents/controller/fwu-learning-contents.controller.ts index f9e7bd6a238..82101391cb4 100644 --- a/apps/server/src/modules/fwu-learning-contents/controller/fwu-learning-contents.controller.ts +++ b/apps/server/src/modules/fwu-learning-contents/controller/fwu-learning-contents.controller.ts @@ -1,4 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, @@ -10,13 +11,12 @@ import { StreamableFile, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { Authenticate } from '@modules/authentication'; import { Request, Response } from 'express'; import { FwuLearningContentsUc } from '../uc/fwu-learning-contents.uc'; import { GetFwuLearningContentParams } from './dto/fwu-learning-contents.params'; @ApiTags('fwu') -@Authenticate('jwt') +@JwtAuthentication() @Controller('fwu') export class FwuLearningContentsController { constructor(private readonly fwuLearningContentsUc: FwuLearningContentsUc) {} 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 a1a6671358d..e8c02d83ba9 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts @@ -2,7 +2,6 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { MongoDatabaseModuleOptions } from '@infra/database/mongo-memory-database/types'; import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; import { SystemEntity } from '@modules/system/entity'; import { HttpModule } from '@nestjs/axios'; @@ -22,7 +21,6 @@ const imports = [ entities: [User, AccountEntity, Role, SchoolEntity, SystemEntity, SchoolYearEntity], }), AuthorizationModule, - AuthenticationModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule, CoreModule, 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 acf829e15d3..d6736208ecd 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,3 +1,4 @@ +import { AuthGuardModule } from '@infra/auth-guard'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; @@ -12,7 +13,6 @@ import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@sr import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; -import { AuthenticationModule } from '../authentication/authentication.module'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; import { config, s3Config } from './fwu-learning-contents.config'; import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; @@ -26,7 +26,6 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @Module({ imports: [ AuthorizationModule, - AuthenticationModule, CoreModule, LoggerModule, HttpModule, @@ -44,6 +43,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }), ConfigModule.forRoot(createConfigModuleOptions(config)), S3ClientModule.register([s3Config]), + AuthGuardModule, ], controllers: [FwuLearningContentsController], providers: [FwuLearningContentsUc], diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index c733c6513a2..1142323a4dd 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Group } from '@modules/group'; import { Controller, ForbiddenException, Get, HttpStatus, Param, Query, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -22,7 +22,7 @@ import { import { GroupResponseMapper } from './mapper'; @ApiTags('Group') -@Authenticate('jwt') +@JwtAuthentication() @Controller('groups') export class GroupController { constructor(private readonly groupUc: GroupUc, private readonly classGroupUc: ClassGroupUc) {} 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 0c7251e6134..333982d889d 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 @@ -1,9 +1,10 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; +import { S3ClientAdapter } from '@infra/s3-client'; import { H5PAjaxEndpoint } from '@lumieducation/h5p-server'; import { EntityManager } from '@mikro-orm/core'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { S3ClientAdapter } from '@infra/s3-client'; import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; @@ -25,6 +26,8 @@ describe('H5PEditor Controller (api)', () => { .useValue(createMock()) .overrideProvider(H5PAjaxEndpoint) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 a45e05fc981..8f532cb1285 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,111 +1,134 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest/lib/mocks'; +import { createMock } from '@golevelup/ts-jest/lib/mocks'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; +import { H5PEditor } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { Permission } from '@shared/domain/interface'; import { cleanupCollections, - mapUserToCurrentUser, - roleFactory, - schoolEntityFactory, - userFactory, + h5pContentFactory, + lessonFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { Request } from 'express'; -import request from 'supertest'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; -import { H5PEditorUc } from '../../uc/h5p.uc'; - -class API { - constructor(private app: INestApplication) { - this.app = app; - } - - async deleteH5pContent(contentId: string) { - return request(this.app.getHttpServer()).post(`/h5p-editor/delete/${contentId}`); - } -} - -const setup = () => { - const contentId = new ObjectId(0).toString(); - const notExistingContentId = new ObjectId(1).toString(); - const badContentId = ''; - - return { contentId, notExistingContentId, badContentId }; -}; describe('H5PEditor Controller (api)', () => { let app: INestApplication; - let api: API; let em: EntityManager; - let currentUser: ICurrentUser; - let h5PEditorUc: DeepMocked; + let testApiClient: TestApiClient; beforeAll(async () => { - const module = await Test.createTestingModule({ + const moduleFixture = await Test.createTestingModule({ imports: [H5PEditorTestModule], }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) - .overrideProvider(H5PEditorUc) - .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) + .overrideProvider(H5PEditor) + .useValue(createMock()) .compile(); - app = module.createNestApplication(); + app = moduleFixture.createNestApplication(); await app.init(); - h5PEditorUc = module.get(H5PEditorUc); - - api = new API(app); - em = module.get(EntityManager); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'h5p-editor'); }); afterAll(async () => { await app.close(); }); + beforeEach(async () => { + await cleanupCollections(em); + }); + describe('delete h5p content', () => { - beforeEach(async () => { - await cleanupCollections(em); - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], - }); - const user = userFactory.build({ school, roles }); + describe('when no user is logged in', () => { + it('should return 401', async () => { + const someId = new ObjectId().toHexString(); - await em.persistAndFlush([user, school]); - em.clear(); + const response = await testApiClient.post(`delete/${someId}`); - currentUser = mapUserToCurrentUser(user); + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); }); - describe('with valid request params', () => { - it('should return 200 status', async () => { - const { contentId } = setup(); - h5PEditorUc.deleteH5pContent.mockResolvedValueOnce(true); - const response = await api.deleteH5pContent(contentId); - expect(response.status).toEqual(201); + describe('when user is logged in', () => { + describe('when id in params is not a mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.post(`delete/123`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['contentId must be a mongodb id'], field: ['contentId'] }], + }) + ); + }); }); - }); - describe('with bad request params', () => { - it('should return 500 status', async () => { - const { notExistingContentId } = setup(); - h5PEditorUc.deleteH5pContent.mockRejectedValueOnce(new Error('Could not delete H5P content')); - const response = await api.deleteH5pContent(notExistingContentId); - expect(response.status).toEqual(500); + describe('when requested content is not found', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 404', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.post(`delete/${someId}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when content is found', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const lesson = lessonFactory.build(); + const h5pContent = h5pContentFactory.build({ parentId: lesson.id }); + + await em.persistAndFlush([lesson, h5pContent, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { contentId: h5pContent.id, loggedInClient }; + }; + + it('should respond with code 201', async () => { + const { contentId, loggedInClient } = await setup(); + + const response = await loggedInClient.post(`delete/${contentId}`); + + expect(response.status).toEqual(HttpStatus.CREATED); + }); }); }); }); 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 5800d5eb99f..5ff8fa2f3d4 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 @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; import { IFileStats, ILibraryName } from '@lumieducation/h5p-server'; import { ContentMetadata } from '@lumieducation/h5p-server/build/src/ContentMetadata'; @@ -92,6 +93,8 @@ describe('H5PEditor Controller (api)', () => { .useValue(createMock()) .overrideProvider(TemporaryFileStorage) .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); 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 c1212567e13..e7a575f4a3f 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,39 +1,21 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest/lib/mocks'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; +import { H5PEditor, IContentMetadata } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { Permission } from '@shared/domain/interface'; import { cleanupCollections, - mapUserToCurrentUser, - roleFactory, - schoolEntityFactory, - userFactory, + h5pContentFactory, + lessonFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { Request } from 'express'; -import request from 'supertest'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; -import { H5PEditorUc } from '../../uc/h5p.uc'; -class API { - constructor(private app: INestApplication) { - this.app = app; - } - - async emptyEditor() { - return request(this.app.getHttpServer()).get(`/h5p-editor/edit/de`); - } - - async editH5pContent(contentId: string) { - return request(this.app.getHttpServer()).get(`/h5p-editor/edit/${contentId}/de`); - } -} - -const setup = () => { +const buildContent = () => { const contentId = new ObjectId(0).toString(); const notExistingContentId = new ObjectId(1).toString(); const badContentId = ''; @@ -44,10 +26,10 @@ const setup = () => { }; const exampleContent = { - h5p: {}, + h5p: createMock(), library: 'ExampleLib-1.0', params: { - metadata: {}, + metadata: createMock(), params: { anything: true }, }, }; @@ -57,104 +39,180 @@ const setup = () => { describe('H5PEditor Controller (api)', () => { let app: INestApplication; - let api: API; + let testApiClient: TestApiClient; let em: EntityManager; - let currentUser: ICurrentUser; - let h5PEditorUc: DeepMocked; + let h5pEditor: DeepMocked; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [H5PEditorTestModule], }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) + .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) - .overrideProvider(H5PEditorUc) - .useValue(createMock()) + .overrideProvider(H5PEditor) + .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); await app.init(); - h5PEditorUc = module.get(H5PEditorUc); - api = new API(app); + testApiClient = new TestApiClient(app, '/h5p-editor/edit'); em = module.get(EntityManager); + h5pEditor = module.get(H5PEditor); }); afterAll(async () => { await app.close(); }); + beforeEach(async () => { + jest.resetAllMocks(); + await cleanupCollections(em); + }); + describe('get new h5p editor', () => { - beforeEach(async () => { - await cleanupCollections(em); - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + describe('when user is not logged in', () => { + it('should return UNAUTHORIZED status', async () => { + const response = await testApiClient.get('de'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); }); - const user = userFactory.build({ school, roles }); + }); - await em.persistAndFlush([user, school]); - em.clear(); + describe('when user is logged in', () => { + describe('when editor is created successfully', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - currentUser = mapUserToCurrentUser(user); - }); - describe('with valid request params', () => { - it('should return 200 status', async () => { - const { editorModel } = setup(); - // @ts-expect-error partial object - h5PEditorUc.getEmptyH5pEditor.mockResolvedValueOnce(editorModel); - const response = await api.emptyEditor(); - expect(response.status).toEqual(200); + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const { editorModel } = buildContent(); + h5pEditor.render.mockResolvedValueOnce(editorModel); + + return { loggedInClient }; + }; + + it('should return OK status', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get('de'); + + expect(response.status).toEqual(HttpStatus.OK); + }); }); - }); - describe('with bad request params', () => { - it('should return 500 status', async () => { - h5PEditorUc.getEmptyH5pEditor.mockRejectedValueOnce(new Error('Could not get H5P editor')); - const response = await api.emptyEditor(); - expect(response.status).toEqual(500); + + describe('when editor throws error', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + h5pEditor.render.mockRejectedValueOnce(new Error('Could not get H5P editor')); + + return { loggedInClient }; + }; + + it('should return INTERNAL_SERVER_ERROR status', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get('de'); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); }); }); }); describe('get h5p editor', () => { - beforeEach(async () => { - await cleanupCollections(em); - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + describe('when user is not logged in', () => { + it('should return UNAUTHORIZED status', async () => { + const response = await testApiClient.get('123/de'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); }); - const user = userFactory.build({ school, roles }); + }); - await em.persistAndFlush([user, school]); - em.clear(); + describe('when user is logged in', () => { + describe('when editor is returned successfully', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const lesson = lessonFactory.build(); + const h5pContent = h5pContentFactory.build({ parentId: lesson.id }); - currentUser = mapUserToCurrentUser(user); - }); - describe('with valid request params', () => { - it('should return 200 status', async () => { - const { contentId, editorModel, exampleContent } = setup(); - // @ts-expect-error partial object - h5PEditorUc.getH5pEditor.mockResolvedValueOnce({ editorModel, content: exampleContent }); - const response = await api.editH5pContent(contentId); - expect(response.status).toEqual(200); + await em.persistAndFlush([teacherAccount, teacherUser, lesson, h5pContent]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const { editorModel, exampleContent } = buildContent(); + h5pEditor.render.mockResolvedValueOnce({ editorModel, content: exampleContent }); + h5pEditor.getContent.mockResolvedValueOnce(exampleContent); + + return { loggedInClient, contentId: h5pContent.id }; + }; + + it('should return 200 status', async () => { + const { contentId, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${contentId}/de`); + + expect(response.status).toEqual(200); + }); }); - }); - describe('with bad request params', () => { - it('should return 500 status', async () => { - const { notExistingContentId } = setup(); - h5PEditorUc.getH5pEditor.mockRejectedValueOnce(new Error('Could not get H5P editor')); - const response = await api.editH5pContent(notExistingContentId); - expect(response.status).toEqual(500); + + describe('when content is not existing', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const { contentId } = buildContent(); + + return { loggedInClient, contentId }; + }; + + it('should return 200 status', async () => { + const { contentId, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${contentId}/de`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when id is not a mongo id', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return 200 status', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`123/de`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + }); }); }); }); 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 ca3e04bf226..36012a5bd32 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,36 +1,21 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest/lib/mocks'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { S3ClientAdapter } from '@infra/s3-client'; -import { IPlayerModel } from '@lumieducation/h5p-server'; +import { H5PPlayer, IPlayerModel } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { Permission } from '@shared/domain/interface'; import { cleanupCollections, - mapUserToCurrentUser, - roleFactory, - schoolEntityFactory, - userFactory, + h5pContentFactory, + lessonFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { Request } from 'express'; -import request from 'supertest'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; -import { H5PEditorUc } from '../../uc/h5p.uc'; -class API { - constructor(private app: INestApplication) { - this.app = app; - } - - async getPlayer(contentId: string) { - return request(this.app.getHttpServer()).get(`/h5p-editor/play/${contentId}`); - } -} - -const setup = () => { +const buildContent = () => { const contentId = new ObjectId(0).toString(); const notExistingContentId = new ObjectId(1).toString(); @@ -49,36 +34,30 @@ const setup = () => { describe('H5PEditor Controller (api)', () => { let app: INestApplication; - let api: API; let em: EntityManager; - let currentUser: ICurrentUser; - let h5PEditorUc: DeepMocked; + let h5pPlayer: DeepMocked; + let testApiClient: TestApiClient; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [H5PEditorTestModule], }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) .overrideProvider(H5P_CONTENT_S3_CONNECTION) .useValue(createMock()) .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) - .overrideProvider(H5PEditorUc) - .useValue(createMock()) + .overrideProvider(H5PPlayer) + .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); - h5PEditorUc = module.get(H5PEditorUc); + h5pPlayer = module.get(H5PPlayer); await app.init(); - api = new API(app); + testApiClient = new TestApiClient(app, '/h5p-editor/play'); + em = module.get(EntityManager); }); @@ -86,34 +65,89 @@ describe('H5PEditor Controller (api)', () => { await app.close(); }); + beforeEach(async () => { + jest.resetAllMocks(); + await cleanupCollections(em); + }); + describe('get h5p player', () => { - beforeEach(async () => { - await cleanupCollections(em); - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], + describe('when user is not logged in', () => { + it('should return UNAUTHORIZED status', async () => { + const mongoId = new ObjectId().toHexString(); + const response = await testApiClient.get(mongoId); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); }); - const user = userFactory.build({ school, roles }); + }); - await em.persistAndFlush([user, school]); - em.clear(); + describe('when user is not logged in', () => { + describe('when content is existing', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const lesson = lessonFactory.build(); + const h5pContent = h5pContentFactory.build({ parentId: lesson.id }); - currentUser = mapUserToCurrentUser(user); + await em.persistAndFlush([teacherAccount, teacherUser, lesson, h5pContent]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + const { playerResult } = buildContent(); + + h5pPlayer.render.mockResolvedValueOnce(playerResult); + + return { loggedInClient, contentId: h5pContent.id }; + }; + + it('should return 200 status', async () => { + const { loggedInClient, contentId } = await setup(); + + const response = await loggedInClient.get(contentId); + + expect(response.status).toEqual(200); + }); + }); }); - describe('with valid request params', () => { + + describe('when content is not existing', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + const contentId = new ObjectId().toHexString(); + + return { loggedInClient, contentId }; + }; + it('should return 200 status', async () => { - const { contentId, playerResult } = setup(); - h5PEditorUc.getH5pPlayer.mockResolvedValueOnce(playerResult); - const response = await api.getPlayer(contentId); - expect(response.status).toEqual(200); + const { loggedInClient, contentId } = await setup(); + + const response = await loggedInClient.get(contentId); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); }); - describe('with bad request params', () => { - it('should return 500 status', async () => { - const { notExistingContentId } = setup(); - h5PEditorUc.getH5pPlayer.mockRejectedValueOnce(new Error('Could not get H5P player')); - const response = await api.getPlayer(notExistingContentId); - expect(response.status).toEqual(500); + + describe('when id is not a mongo id', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get('123'); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); }); }); 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 0e1d5a13686..8da4914e2ae 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,20 +1,20 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest/lib/mocks'; -import { IContentMetadata } from '@lumieducation/h5p-server'; +import { createMock, DeepMocked } from '@golevelup/ts-jest/lib/mocks'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { H5PEditor, IContentMetadata } from '@lumieducation/h5p-server'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { S3ClientAdapter } from '@infra/s3-client'; -import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { cleanupCollections, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { H5PContentParentType } from '../../entity'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; -import { H5PEditorUc } from '../../uc/h5p.uc'; import { PostH5PContentCreateParams } from '../dto'; describe('H5PEditor Controller (api)', () => { let app: INestApplication; let em: EntityManager; - let h5PEditorUc: DeepMocked; + let h5pEditor: DeepMocked; let testApiClient: TestApiClient; beforeAll(async () => { @@ -25,13 +25,15 @@ describe('H5PEditor Controller (api)', () => { .useValue(createMock()) .overrideProvider(H5P_LIBRARIES_S3_CONNECTION) .useValue(createMock()) - .overrideProvider(H5PEditorUc) - .useValue(createMock()) + .overrideProvider(H5PEditor) + .useValue(createMock()) + .overrideProvider(AuthorizationClientAdapter) + .useValue(createMock()) .compile(); app = module.createNestApplication(); await app.init(); - h5PEditorUc = module.get(H5PEditorUc); + h5pEditor = module.get(H5PEditor); em = module.get(EntityManager); testApiClient = new TestApiClient(app, 'h5p-editor'); }); @@ -40,6 +42,11 @@ describe('H5PEditor Controller (api)', () => { await app.close(); }); + beforeEach(async () => { + jest.resetAllMocks(); + await cleanupCollections(em); + }); + describe('create h5p content', () => { describe('with valid request params', () => { const setup = async () => { @@ -77,19 +84,24 @@ describe('H5PEditor Controller (api)', () => { em.clear(); const loggedInClient = await testApiClient.login(studentAccount); + const result1 = { id, metadata }; + h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce(result1); + return { id, metadata, loggedInClient, params }; }; - it('should return 201 status', async () => { - const { id, metadata, loggedInClient, params } = await setup(); - const result1 = { id, metadata }; - h5PEditorUc.createH5pContentGetMetadata.mockResolvedValueOnce(result1); + + it('should return CREATED status', async () => { + const { loggedInClient, params } = await setup(); + const response = await loggedInClient.post(`/edit`, params); - expect(response.status).toEqual(201); + + expect(response.status).toEqual(HttpStatus.CREATED); }); }); }); + describe('save h5p content', () => { - describe('with valid request params', () => { + describe('when request params are valid', () => { const setup = async () => { const contentId = new ObjectId(0); const id = '0000000'; @@ -125,20 +137,23 @@ describe('H5PEditor Controller (api)', () => { em.clear(); const loggedInClient = await testApiClient.login(studentAccount); + const result1 = { id, metadata }; + h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce(result1); + return { contentId, id, metadata, loggedInClient, params }; }; - it('should return 201 status', async () => { - const { contentId, id, metadata, loggedInClient, params } = await setup(); - const result1 = { id, metadata }; - h5PEditorUc.saveH5pContentGetMetadata.mockResolvedValueOnce(result1); + + it('should return CREATED status', async () => { + const { contentId, loggedInClient, params } = await setup(); + const response = await loggedInClient.post(`/edit/${contentId.toString()}`, params); - expect(response.status).toEqual(201); + expect(response.status).toEqual(HttpStatus.CREATED); }); }); - describe('with bad request params', () => { + + describe('when id is not mongo id', () => { const setup = async () => { - const notExistingContentId = new ObjectId(1); const params: PostH5PContentCreateParams = { parentType: H5PContentParentType.Lesson, parentId: new ObjectId().toString(), @@ -162,14 +177,15 @@ describe('H5PEditor Controller (api)', () => { em.clear(); const loggedInClient = await testApiClient.login(studentAccount); - return { notExistingContentId, loggedInClient, params }; + return { loggedInClient, params }; }; - it('should return 500 status', async () => { - const { notExistingContentId, loggedInClient, params } = await setup(); - h5PEditorUc.saveH5pContentGetMetadata.mockRejectedValueOnce(new Error('Could not save H5P content')); - const response = await loggedInClient.post(`/edit/${notExistingContentId.toString()}`, params); - expect(response.status).toEqual(500); + it('should return BAD_REQUEST status', async () => { + const { loggedInClient, params } = await setup(); + + const response = await loggedInClient.post(`/edit/123`, params); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); }); }); 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 88809092e92..b193423e750 100644 --- a/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts +++ b/apps/server/src/modules/h5p-editor/controller/h5p-editor.controller.ts @@ -1,5 +1,4 @@ -import { CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Authenticate } from '@modules/authentication/decorator/auth.decorator'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { BadRequestException, Body, @@ -39,7 +38,7 @@ import { AjaxPostBodyParamsTransformPipe } from './dto/ajax/post.body.params.tra import { H5PEditorModelContentResponse, H5PEditorModelResponse, H5PSaveResponse } from './dto/h5p-editor.response'; @ApiTags('h5p-editor') -@Authenticate('jwt') +@JwtAuthentication() @Controller('h5p-editor') export class H5PEditorController { constructor(private h5pEditorUc: H5PEditorUc) {} 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 73b218380e5..0248552c576 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,9 +1,8 @@ +import { AuthorizationClientModule } from '@infra/authorization-client'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; -import { AuthenticationModule } from '@modules/authentication'; import { AuthenticationApiModule } from '@modules/authentication/authentication-api.module'; -import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; import { UserModule } from '@modules/user'; import { DynamicModule, Module } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain/entity'; @@ -11,7 +10,7 @@ import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { H5PEditorController } from './controller'; import { H5PContent } from './entity'; -import { s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; +import { authorizationClientConfig, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; import { H5PEditorModule } from './h5p-editor.module'; import { H5PAjaxEndpointProvider, H5PEditorProvider, H5PPlayerProvider } from './provider'; import { H5PContentRepo, LibraryRepo } from './repo'; @@ -22,8 +21,7 @@ const imports = [ H5PEditorModule, MongoMemoryDatabaseModule.forRoot({ entities: [...ALL_ENTITIES, H5PContent] }), AuthenticationApiModule, - AuthorizationReferenceModule, - AuthenticationModule, + AuthorizationClientModule.register(authorizationClientConfig), UserModule, CoreModule, LoggerModule, 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 e2364120443..e00f32542de 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts @@ -1,10 +1,22 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthorizationClientConfig } from '@infra/authorization-client'; import { S3Config } from '@infra/s3-client'; import { LanguageType } from '@shared/domain/interface'; +import { CoreModuleConfig } from '@src/core'; -const h5pEditorConfig = { +export interface H5PEditorConfig extends CoreModuleConfig, AuthorizationClientConfig { + NEST_LOG_LEVEL: string; + INCOMING_REQUEST_TIMEOUT: number; +} + +export const authorizationClientConfig: AuthorizationClientConfig = { + basePath: `${Configuration.get('API_HOST') as string}/v3/`, +}; + +const h5pEditorConfig: H5PEditorConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('H5P_EDITOR__INCOMING_REQUEST_TIMEOUT') as number, + ...authorizationClientConfig, }; export const translatorConfig = { 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 5045d097d92..4fd634cdf94 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts @@ -1,9 +1,9 @@ +import { AuthGuardModule } from '@infra/auth-guard'; +import { AuthorizationClientModule } from '@infra/authorization-client'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { AuthenticationModule } from '@modules/authentication'; -import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; import { UserModule } from '@modules/user'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -13,7 +13,7 @@ import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; import { H5PEditorController } from './controller/h5p-editor.controller'; import { H5PContent, InstalledLibrary } from './entity'; -import { config, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; +import { authorizationClientConfig, config, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; import { H5PAjaxEndpointProvider, H5PEditorProvider, H5PPlayerProvider } from './provider'; import { H5PContentRepo, LibraryRepo } from './repo'; import { ContentStorage, LibraryStorage, TemporaryFileStorage } from './service'; @@ -26,8 +26,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }; const imports = [ - AuthenticationModule, - AuthorizationReferenceModule, + AuthorizationClientModule.register(authorizationClientConfig), CoreModule, UserModule, RabbitMQWrapperModule, @@ -44,6 +43,7 @@ const imports = [ }), ConfigModule.forRoot(createConfigModuleOptions(config)), S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), + AuthGuardModule, ]; const controllers = [H5PEditorController]; 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 index 1b760036db6..8861d0c3260 100644 --- a/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.ts +++ b/apps/server/src/modules/h5p-editor/mapper/h5p-content.mapper.ts @@ -1,12 +1,12 @@ +import { AuthorizationBodyParamsReferenceType } from '@infra/authorization-client'; import { NotImplementedException } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { H5PContentParentType } from '../entity'; export class H5PContentMapper { - static mapToAllowedAuthorizationEntityType(type: H5PContentParentType): AuthorizableReferenceType { - const types = new Map(); + static mapToAllowedAuthorizationEntityType(type: H5PContentParentType): AuthorizationBodyParamsReferenceType { + const types = new Map(); - types.set(H5PContentParentType.Lesson, AuthorizableReferenceType.Lesson); + types.set(H5PContentParentType.Lesson, AuthorizationBodyParamsReferenceType.LESSONS); const res = types.get(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 b016182bce8..ee769abfba9 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 @@ -1,11 +1,11 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { H5PAjaxEndpoint, H5PEditor, H5PPlayer, H5pError } from '@lumieducation/h5p-server'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; import { LanguageType } from '@shared/domain/interface'; import { setupEntities } from '@shared/testing'; -import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { H5PContentRepo } from '../repo'; import { LibraryStorage } from '../service'; @@ -42,8 +42,8 @@ describe('H5P Ajax', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, 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 040d5817f8a..9f793884282 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,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { AuthorizationClientAdapter, AuthorizationContextBuilder } from '@infra/authorization-client'; import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { h5pContentFactory, setupEntities } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { H5PAjaxEndpointProvider } from '../provider'; import { H5PContentRepo } from '../repo'; @@ -30,7 +30,7 @@ describe('save or create H5P content', () => { let uc: H5PEditorUc; let h5pEditor: DeepMocked; let h5pContentRepo: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -54,8 +54,8 @@ describe('save or create H5P content', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, @@ -67,7 +67,7 @@ describe('save or create H5P content', () => { uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); h5pContentRepo = module.get(H5PContentRepo); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); await setupEntities(); }); @@ -86,18 +86,17 @@ describe('save or create H5P content', () => { h5pContentRepo.findById.mockResolvedValueOnce(content); h5pEditor.deleteContent.mockResolvedValueOnce(); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { content, mockCurrentUser }; }; - it('should call authorizationReferenceService.checkPermissionByReferences', async () => { + it('should call authorizationClientAdapter.checkPermissionsByReference', async () => { const { content, mockCurrentUser } = setup(); await uc.deleteH5pContent(mockCurrentUser, content.id); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( content.parentType, content.parentId, AuthorizationContextBuilder.write([]) @@ -149,7 +148,7 @@ describe('save or create H5P content', () => { const { content, mockCurrentUser } = createParams(); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { content, mockCurrentUser }; }; @@ -170,7 +169,7 @@ describe('save or create H5P content', () => { const error = new Error('test'); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); h5pEditor.deleteContent.mockRejectedValueOnce(error); return { error, content, mockCurrentUser }; 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 c1c7987ace8..f9c6e398930 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,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { AuthorizationClientAdapter, AuthorizationContextBuilder } from '@infra/authorization-client'; import { H5PAjaxEndpoint, H5PEditor, IPlayerModel } from '@lumieducation/h5p-server'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { h5pContentFactory, setupEntities } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { Request } from 'express'; import { Readable } from 'stream'; @@ -47,7 +47,7 @@ describe('H5P Files', () => { let libraryStorage: DeepMocked; let ajaxEndpointService: DeepMocked; let h5pContentRepo: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -76,8 +76,8 @@ describe('H5P Files', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, @@ -90,7 +90,7 @@ describe('H5P Files', () => { libraryStorage = module.get(LibraryStorage); ajaxEndpointService = module.get(H5PAjaxEndpoint); h5pContentRepo = module.get(H5PContentRepo); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); await setupEntities(); }); @@ -109,18 +109,17 @@ describe('H5P Files', () => { ajaxEndpointService.getContentParameters.mockResolvedValueOnce(mockContentParameters); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { content, mockCurrentUser, mockContentParameters }; }; - it('should call authorizationService.checkPermissionByReferences', async () => { + it('should call authorizationClientAdapter.checkPermissionsByReference', async () => { const { content, mockCurrentUser } = setup(); await uc.getContentParameters(content.id, mockCurrentUser); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( content.parentType, content.parentId, AuthorizationContextBuilder.read([]) @@ -172,7 +171,7 @@ describe('H5P Files', () => { const { content, mockCurrentUser } = createParams(); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { content, mockCurrentUser }; }; @@ -191,7 +190,7 @@ describe('H5P Files', () => { const { content, mockCurrentUser } = createParams(); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); ajaxEndpointService.getContentParameters.mockRejectedValueOnce(new Error('test')); return { content, mockCurrentUser }; @@ -223,7 +222,7 @@ describe('H5P Files', () => { }); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); const filename = 'test/file.txt'; @@ -235,8 +234,7 @@ describe('H5P Files', () => { await uc.getContentFile(content.id, filename, requestMock, mockCurrentUser); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( content.parentType, content.parentId, AuthorizationContextBuilder.read([]) @@ -294,7 +292,7 @@ describe('H5P Files', () => { stream: createMock(), }); }); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); const filename = 'test/file.txt'; @@ -326,7 +324,7 @@ describe('H5P Files', () => { rangeCallback?.(100); return createMock(); }); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); const filename = 'test/file.txt'; @@ -399,7 +397,7 @@ describe('H5P Files', () => { ajaxEndpointService.getContentFile.mockRejectedValueOnce(new Error('test')); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); const filename = 'test/file.txt'; 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 386ba608fc0..5543519b495 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,12 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { AuthorizationClientAdapter, AuthorizationContextBuilder } from '@infra/authorization-client'; 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/interface'; import { UserRepo } from '@shared/repo'; import { h5pContentFactory, setupEntities } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { H5PAjaxEndpointProvider } from '../provider'; import { H5PContentRepo } from '../repo'; @@ -44,7 +44,7 @@ describe('get H5P editor', () => { let uc: H5PEditorUc; let h5pEditor: DeepMocked; let h5pContentRepo: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -72,8 +72,8 @@ describe('get H5P editor', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, @@ -85,7 +85,7 @@ describe('get H5P editor', () => { uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); h5pContentRepo = module.get(H5PContentRepo); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); await setupEntities(); }); @@ -159,7 +159,7 @@ describe('get H5P editor', () => { h5pContentRepo.findById.mockResolvedValueOnce(content); h5pEditor.render.mockResolvedValueOnce(editorResponseMock); h5pEditor.getContent.mockResolvedValueOnce(contentResponseMock); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; }; @@ -169,8 +169,7 @@ describe('get H5P editor', () => { await uc.getH5pEditor(mockCurrentUser, content.id, language); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( content.parentType, content.parentId, AuthorizationContextBuilder.write([]) @@ -235,7 +234,7 @@ describe('get H5P editor', () => { const { content, mockCurrentUser, editorResponseMock, contentResponseMock, language } = createParams(); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; }; @@ -261,7 +260,7 @@ describe('get H5P editor', () => { h5pContentRepo.findById.mockResolvedValueOnce(content); h5pEditor.render.mockRejectedValueOnce(error); h5pEditor.getContent.mockRejectedValueOnce(error); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { error, content, mockCurrentUser, editorResponseMock, contentResponseMock, language }; }; 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 c8648cb413c..b64be9bc91d 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,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { AuthorizationClientAdapter, AuthorizationContextBuilder } from '@infra/authorization-client'; import { H5PEditor, H5PPlayer, IPlayerModel } from '@lumieducation/h5p-server'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { h5pContentFactory, setupEntities } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { H5PAjaxEndpointProvider } from '../provider'; import { H5PContentRepo } from '../repo'; @@ -34,7 +34,7 @@ describe('get H5P player', () => { let uc: H5PEditorUc; let h5pPlayer: DeepMocked; let h5pContentRepo: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -58,8 +58,8 @@ describe('get H5P player', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, @@ -71,7 +71,7 @@ describe('get H5P player', () => { uc = module.get(H5PEditorUc); h5pPlayer = module.get(H5PPlayer); h5pContentRepo = module.get(H5PContentRepo); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); await setupEntities(); }); @@ -92,18 +92,17 @@ describe('get H5P player', () => { h5pContentRepo.findById.mockResolvedValueOnce(content); h5pPlayer.render.mockResolvedValueOnce(expectedResult); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { content, mockCurrentUser, expectedResult }; }; - it('should call authorizationService.checkPermissionByReferences', async () => { + it('should call authorizationClientAdapter.checkPermissionsByReference', async () => { const { content, mockCurrentUser } = setup(); await uc.getH5pPlayer(mockCurrentUser, content.id); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( content.parentType, content.parentId, AuthorizationContextBuilder.read([]) @@ -158,7 +157,7 @@ describe('get H5P player', () => { const { content, mockCurrentUser, playerResponseMock } = createParams(); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { content, mockCurrentUser, playerResponseMock }; }; @@ -181,7 +180,7 @@ describe('get H5P player', () => { const error = new Error('test'); h5pContentRepo.findById.mockResolvedValueOnce(content); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); h5pPlayer.render.mockRejectedValueOnce(error); return { error, content, mockCurrentUser, playerResponseMock }; 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 2fba57f5bc2..28402b948db 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,11 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { AuthorizationClientAdapter, AuthorizationContextBuilder } from '@infra/authorization-client'; import { H5PEditor, H5PPlayer } from '@lumieducation/h5p-server'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { h5pContentFactory, setupEntities } from '@shared/testing'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContextBuilder, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { H5PContentParentType } from '../entity'; import { H5PAjaxEndpointProvider } from '../provider'; @@ -37,7 +37,7 @@ describe('save or create H5P content', () => { let module: TestingModule; let uc: H5PEditorUc; let h5pEditor: DeepMocked; - let authorizationReferenceService: DeepMocked; + let authorizationClientAdapter: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -61,8 +61,8 @@ describe('save or create H5P content', () => { useValue: createMock(), }, { - provide: AuthorizationReferenceService, - useValue: createMock(), + provide: AuthorizationClientAdapter, + useValue: createMock(), }, { provide: H5PContentRepo, @@ -73,7 +73,7 @@ describe('save or create H5P content', () => { uc = module.get(H5PEditorUc); h5pEditor = module.get(H5PEditor); - authorizationReferenceService = module.get(AuthorizationReferenceService); + authorizationClientAdapter = module.get(AuthorizationClientAdapter); await setupEntities(); }); @@ -91,12 +91,12 @@ describe('save or create H5P content', () => { const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce({ id: contentId, metadata }); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId }; }; - it('should call authorizationService.checkPermissionByReferences', async () => { + it('should call authorizationClientAdapter.checkPermissionsByReference', async () => { const { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); await uc.saveH5pContentGetMetadata( @@ -109,8 +109,7 @@ describe('save or create H5P content', () => { parentId ); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( H5PContentParentType.Lesson, parentId, AuthorizationContextBuilder.write([]) @@ -160,7 +159,7 @@ describe('save or create H5P content', () => { const setup = () => { const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; }; @@ -190,7 +189,7 @@ describe('save or create H5P content', () => { const error = new Error('test'); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); h5pEditor.saveOrUpdateContentReturnMetaData.mockRejectedValueOnce(error); return { error, contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; @@ -220,12 +219,12 @@ describe('save or create H5P content', () => { const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); h5pEditor.saveOrUpdateContentReturnMetaData.mockResolvedValueOnce({ id: contentId, metadata }); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); return { contentId, parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId }; }; - it('should call authorizationService.checkPermissionByReferences', async () => { + it('should call authorizationClientAdapter.checkPermissionsByReference', async () => { const { parameters, metadata, mainLibraryUbername, mockCurrentUser, parentId } = setup(); await uc.createH5pContentGetMetadata( @@ -237,8 +236,7 @@ describe('save or create H5P content', () => { parentId ); - expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( - mockCurrentUser.userId, + expect(authorizationClientAdapter.checkPermissionsByReference).toBeCalledWith( H5PContentParentType.Lesson, parentId, AuthorizationContextBuilder.write([]) @@ -286,7 +284,7 @@ describe('save or create H5P content', () => { const setup = () => { const { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser } = createParams(); - authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationClientAdapter.checkPermissionsByReference.mockRejectedValueOnce(new ForbiddenException()); return { contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; }; @@ -315,7 +313,7 @@ describe('save or create H5P content', () => { const error = new Error('test'); - authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationClientAdapter.checkPermissionsByReference.mockResolvedValueOnce(); h5pEditor.saveOrUpdateContentReturnMetaData.mockRejectedValueOnce(error); return { error, contentId, mockCurrentUser, parameters, metadata, mainLibraryUbername, parentId }; 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 a6794b544a4..ddbd75f0fe7 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts @@ -1,3 +1,9 @@ +import { ICurrentUser } from '@infra/auth-guard'; +import { + AuthorizationClientAdapter, + AuthorizationContextBuilder, + AuthorizationContextParams, +} from '@infra/authorization-client'; import { AjaxSuccessResponse, H5PAjaxEndpoint, @@ -23,9 +29,6 @@ import { } from '@nestjs/common'; import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ICurrentUser } from '@src/modules/authentication'; -import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; -import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; import { Request } from 'express'; import { AjaxGetQueryParams, AjaxPostBodyParams, AjaxPostQueryParams } from '../controller/dto'; @@ -45,18 +48,17 @@ export class H5PEditorUc { private readonly h5pAjaxEndpoint: H5PAjaxEndpoint, private readonly libraryService: LibraryStorage, private readonly userService: UserService, - private readonly authorizationReferenceService: AuthorizationReferenceService, + private readonly authorizationClientAdapter: AuthorizationClientAdapter, private readonly h5pContentRepo: H5PContentRepo ) {} private async checkContentPermission( - userId: EntityId, parentType: H5PContentParentType, parentId: EntityId, - context: AuthorizationContext + context: AuthorizationContextParams ) { const allowedType = H5PContentMapper.mapToAllowedAuthorizationEntityType(parentType); - await this.authorizationReferenceService.checkPermissionByReferences(userId, allowedType, parentId, context); + await this.authorizationClientAdapter.checkPermissionsByReference(allowedType, parentId, context); } private fakeUndefinedAsString = () => { @@ -168,7 +170,7 @@ 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([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.read([])); const user = this.changeUserType(currentUser); @@ -188,7 +190,7 @@ export class H5PEditorUc { currentUser: ICurrentUser ): Promise { const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.read([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.read([])); const user = this.changeUserType(currentUser); @@ -247,7 +249,7 @@ export class H5PEditorUc { public async getH5pPlayer(currentUser: ICurrentUser, contentId: string): Promise { const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.read([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.read([])); const user = this.changeUserType(currentUser); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -272,7 +274,7 @@ export class H5PEditorUc { public async getH5pEditor(currentUser: ICurrentUser, contentId: string, language: LanguageType) { const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.write([])); const user = this.changeUserType(currentUser); @@ -289,7 +291,7 @@ export class H5PEditorUc { public async deleteH5pContent(currentUser: ICurrentUser, contentId: string): Promise { const { parentType, parentId } = await this.h5pContentRepo.findById(contentId); - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.write([])); const user = this.changeUserType(currentUser); let deletedContent = false; @@ -314,7 +316,7 @@ export class H5PEditorUc { parentType: H5PContentParentType, parentId: EntityId ): Promise<{ id: string; metadata: IContentMetadata }> { - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.write([])); const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); const fakeAsString = this.fakeUndefinedAsString(); @@ -339,7 +341,7 @@ export class H5PEditorUc { parentType: H5PContentParentType, parentId: EntityId ): Promise<{ id: string; metadata: IContentMetadata }> { - await this.checkContentPermission(currentUser.userId, parentType, parentId, AuthorizationContextBuilder.write([])); + await this.checkContentPermission(parentType, parentId, AuthorizationContextBuilder.write([])); const user = this.createAugmentedLumiUser(currentUser, parentType, parentId); 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 c41abc21d77..b191b26a0e8 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 @@ -14,6 +14,7 @@ import { } from '@shared/testing'; import { readFile } from 'node:fs/promises'; import { CourseMetadataListResponse } from '../dto'; +import { CourseCommonCartridgeMetadataResponse } from '../dto/course-cc-metadata.response'; const createStudent = () => { const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]); @@ -243,4 +244,30 @@ describe('Course Controller (API)', () => { expect(data[teacher.user.id].length).toBeGreaterThan(0); }); }); + + describe('[GET] /courses/:courseId/cc-metadata', () => { + const setup = async () => { + const teacher = createTeacher(); + const course = courseFactory.buildWithId({ + teachers: [teacher.user], + students: [], + }); + + await em.persistAndFlush([teacher.account, teacher.user, course]); + em.clear(); + + return { course, teacher }; + }; + + it('should return common cartridge metadata of a course', async () => { + const { course, teacher } = await setup(); + + const loggedInClient = await testApiClient.login(teacher.account); + const response = await loggedInClient.get(`${course.id}/cc-metadata`); + const data = response.body as CourseCommonCartridgeMetadataResponse; + + expect(response.statusCode).toBe(200); + expect(data.id).toBe(course.id); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/controller/api-test/dashboard.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/dashboard.api.spec.ts index ace1c8d68c1..2712c0e6c32 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/dashboard.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/dashboard.api.spec.ts @@ -1,6 +1,5 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { DashboardResponse } from '@modules/learnroom/controller/dto'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts index 17be0a523dd..98960bb4a43 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts @@ -1,8 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts index 55fb9b02665..79508014c1f 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts @@ -1,15 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { CopyApiResponse } from '@modules/copy-helper'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { SingleColumnBoardResponse } from '@modules/learnroom/controller/dto'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacyBoard, Course, Task } from '@shared/domain/entity'; +import { Course, LegacyBoard, Task } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { boardFactory, diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 0a801a0aed2..3d0d8314651 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -32,9 +32,10 @@ import { CourseExportUc, CourseImportUc, CourseSyncUc, CourseUc } from '../uc'; import { CommonCartridgeFileValidatorPipe } from '../utils'; import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; import { CourseExportBodyParams } from './dto/course-export.body.params'; +import { CourseCommonCartridgeMetadataResponse } from './dto/course-cc-metadata.response'; @ApiTags('Courses') -@Authenticate('jwt') +@JwtAuthentication() @Controller('courses') export class CourseController { constructor( @@ -129,4 +130,16 @@ export class CourseController { [currentUser.userId]: permissions, }; } + + @Get(':courseId/cc-metadata') + @ApiOperation({ summary: 'Get common cartridge metadata of a course by Id.' }) + @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error.' }) + public async getCourseCcMetadataById( + @Param() param: CourseUrlParams + ): Promise { + const course = await this.courseUc.findCourseById(param.courseId); + + return CourseMapper.mapToCommonCartridgeMetadataResponse(course); + } } diff --git a/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts b/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts index 082ab2949b2..9433419bde6 100644 --- a/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts @@ -1,7 +1,7 @@ -import { ICurrentUser } from '@modules/authentication'; import { Test, TestingModule } from '@nestjs/testing'; import { DashboardEntity, GridElement, GridPosition } from '@shared/domain/entity'; import { EntityId, LearnroomMetadata, LearnroomTypes } from '@shared/domain/types'; +import { currentUserFactory } from '@shared/testing'; import { DashboardUc } from '../uc/dashboard.uc'; import { DashboardController } from './dashboard.controller'; import { DashboardResponse } from './dto'; @@ -60,7 +60,7 @@ describe('dashboard uc', () => { const dashboard = new DashboardEntity('someid', { grid: [], userId: 'userId' }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const response = await controller.findForUser(currentUser); expect(response instanceof DashboardResponse).toEqual(true); @@ -71,7 +71,7 @@ describe('dashboard uc', () => { const dashboard = new DashboardEntity('someid', { grid: [], userId: 'userId' }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const response = await controller.findForUser(currentUser); expect(response instanceof DashboardResponse).toEqual(true); @@ -93,7 +93,7 @@ describe('dashboard uc', () => { }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const response = await controller.findForUser(currentUser); expect(response instanceof DashboardResponse).toEqual(true); @@ -105,10 +105,10 @@ describe('dashboard uc', () => { const dashboard = new DashboardEntity('someid', { grid: [], userId: 'userId' }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); await controller.findForUser(currentUser); - expect(spy).toHaveBeenCalledWith('userId'); + expect(spy).toHaveBeenCalledWith(currentUser.userId); }); }); @@ -128,7 +128,7 @@ describe('dashboard uc', () => { }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); await controller.moveElement( { dashboardId: 'dashboardId' }, { from: { x: 1, y: 2 }, to: { x: 2, y: 1 } }, @@ -152,7 +152,7 @@ describe('dashboard uc', () => { }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const response = await controller.moveElement( { dashboardId: 'dashboardId' }, { @@ -184,7 +184,7 @@ describe('dashboard uc', () => { }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); await controller.patchGroup({ dashboardId: 'dashboardId' }, 3, 4, { title: 'groupTitle' }, currentUser); expect(spy).toHaveBeenCalledWith('dashboardId', { x: 3, y: 4 }, 'groupTitle', currentUser.userId); }); @@ -207,7 +207,7 @@ describe('dashboard uc', () => { }); return Promise.resolve(dashboard); }); - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const response = await controller.patchGroup( { dashboardId: 'dashboardId' }, 3, diff --git a/apps/server/src/modules/learnroom/controller/dashboard.controller.ts b/apps/server/src/modules/learnroom/controller/dashboard.controller.ts index c2c2ff6d9ee..9f83592f1f7 100644 --- a/apps/server/src/modules/learnroom/controller/dashboard.controller.ts +++ b/apps/server/src/modules/learnroom/controller/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { DashboardMapper } from '../mapper/dashboard.mapper'; @@ -6,7 +6,7 @@ import { DashboardUc } from '../uc/dashboard.uc'; import { DashboardResponse, DashboardUrlParams, MoveElementParams, PatchGroupParams } from './dto'; @ApiTags('Dashboard') -@Authenticate('jwt') +@JwtAuthentication() @Controller('dashboard') export class DashboardController { constructor(private readonly dashboardUc: DashboardUc) {} diff --git a/apps/server/src/modules/learnroom/controller/dto/course-cc-metadata.response.ts b/apps/server/src/modules/learnroom/controller/dto/course-cc-metadata.response.ts new file mode 100644 index 00000000000..a2f4c56885a --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-cc-metadata.response.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; + +export class CourseCommonCartridgeMetadataResponse { + constructor(id: EntityId, title: string, copyrightOwners: string[], creationDate?: Date) { + this.id = id; + this.title = title; + this.creationDate = creationDate; + this.copyRightOwners = copyrightOwners; + } + + @ApiProperty({ + description: 'The id of the course', + pattern: '[a-f0-9]{24}', + }) + id: string; + + @ApiProperty({ + description: 'Title of the course', + }) + title: string; + + @ApiProperty({ + description: 'Creation date of the course', + }) + creationDate?: Date; + + @ApiProperty({ + description: 'Copy right owners of the course', + }) + copyRightOwners: string[]; +} 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 ce2c3bf837d..87cddcd4540 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; -import { ICurrentUser } from '@modules/authentication'; import { CopyApiResponse, CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; +import { currentUserFactory } from '@shared/testing'; import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; import { RoomBoardDTO } from '../types'; import { CourseCopyUC } from '../uc/course-copy.uc'; @@ -73,7 +73,7 @@ describe('rooms controller', () => { describe('getRoomBoard', () => { describe('when simple room is fetched', () => { const setup = () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucResult = { roomId: 'id', @@ -125,7 +125,7 @@ describe('rooms controller', () => { describe('patchVisibility', () => { it('should call uc', async () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucSpy = jest.spyOn(uc, 'updateVisibilityOfLegacyBoardElement').mockImplementation(() => Promise.resolve()); await controller.patchElementVisibility( { roomId: 'roomid', elementId: 'elementId' }, @@ -138,17 +138,17 @@ describe('rooms controller', () => { describe('patchOrder', () => { it('should call uc', async () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucSpy = jest.spyOn(uc, 'reorderBoardElements').mockImplementation(() => Promise.resolve()); await controller.patchOrderingOfElements({ roomId: 'roomid' }, { elements: ['id', 'id', 'id'] }, currentUser); - expect(ucSpy).toHaveBeenCalledWith('roomid', 'userId', ['id', 'id', 'id']); + expect(ucSpy).toHaveBeenCalledWith('roomid', currentUser.userId, ['id', 'id', 'id']); }); }); describe('copyCourse', () => { describe('when course should be copied via API call', () => { const setup = () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucResult = { title: 'example title', type: 'COURSE' as CopyElementType, @@ -162,7 +162,7 @@ describe('rooms controller', () => { const { currentUser, ucSpy } = setup(); await controller.copyCourse(currentUser, { roomId: 'roomId' }); - expect(ucSpy).toHaveBeenCalledWith('userId', 'roomId'); + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'roomId'); }); it('should return result of correct type', async () => { @@ -177,7 +177,7 @@ describe('rooms controller', () => { describe('copyLesson', () => { describe('when lesson should be copied via API call', () => { const setup = () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucResult = { title: 'example title', type: 'LESSON' as CopyElementType, @@ -192,7 +192,10 @@ describe('rooms controller', () => { const { currentUser, ucSpy } = setup(); await controller.copyLesson(currentUser, { lessonId: 'lessonId' }, { courseId: 'id' }); - expect(ucSpy).toHaveBeenCalledWith('userId', 'lessonId', { courseId: 'id', userId: 'userId' }); + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'lessonId', { + courseId: 'id', + userId: currentUser.userId, + }); }); it('should return result of correct type', async () => { diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.ts index 7e73104e0d1..b26e346edc1 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.ts +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; @@ -18,7 +18,7 @@ import { } from './dto'; @ApiTags('Rooms') -@Authenticate('jwt') +@JwtAuthentication() @Controller('rooms') export class RoomsController { constructor( diff --git a/apps/server/src/modules/learnroom/mapper/course.mapper.ts b/apps/server/src/modules/learnroom/mapper/course.mapper.ts index 498922cc633..b87bcbff711 100644 --- a/apps/server/src/modules/learnroom/mapper/course.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/course.mapper.ts @@ -1,5 +1,6 @@ import { Course } from '@shared/domain/entity'; import { CourseMetadataResponse } from '../controller/dto'; +import { CourseCommonCartridgeMetadataResponse } from '../controller/dto/course-cc-metadata.response'; export class CourseMapper { static mapToMetadataResponse(course: Course): CourseMetadataResponse { @@ -15,4 +16,17 @@ export class CourseMapper { ); return dto; } + + static mapToCommonCartridgeMetadataResponse(course: Course): CourseCommonCartridgeMetadataResponse { + const courseMetadata = course.getMetadata(); + const teachers = course.teachers.toArray().map((teacher) => `${teacher.firstName} ${teacher.lastName}`); + const courseCCMetadataResopne: CourseCommonCartridgeMetadataResponse = new CourseCommonCartridgeMetadataResponse( + courseMetadata.id, + courseMetadata.title, + teachers, + courseMetadata.startDate + ); + + return courseCCMetadataResopne; + } } diff --git a/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts index 3123988eb95..b00a9fdc4be 100644 --- a/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts @@ -21,7 +21,7 @@ describe('rolename mapper', () => { const setup = () => { const teacherUser = userFactory.asTeacher().buildWithId(); const studentUser = userFactory.asStudent().buildWithId(); - const course = courseFactory.build({ + const course = courseFactory.buildWithId({ teachers: [teacherUser], students: [studentUser], }); @@ -48,6 +48,8 @@ describe('rolename mapper', () => { const role = roleFactory.build({ name: RoleName.EXPERT }); const user = userFactory.buildWithId({ roles: [role] }); - expect(() => RoleNameMapper.mapToRoleName(user, course)).toThrowError('Unsupported role'); + expect(() => RoleNameMapper.mapToRoleName(user, course)).toThrowError( + `Unable to determine a valid role for user ${user.id} in course ${course.id}` + ); }); }); diff --git a/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts b/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts index a755c734a44..88234020cf8 100644 --- a/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts @@ -33,6 +33,8 @@ export class RoleNameMapper { if (RoleNameMapper.isSubstitutionTeacher(user, course)) return RoleName.COURSESUBSTITUTIONTEACHER; if (RoleNameMapper.isStudent(user, course)) return RoleName.STUDENT; - throw new UnprocessableEntityException('Unsupported role'); + throw new UnprocessableEntityException( + `Unable to determine a valid role for user ${user.id} in course ${course.id}` + ); } } 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 3c2a082fd4a..17dbd918dc7 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts @@ -103,4 +103,20 @@ describe('CourseUc', () => { expect(roleService.findByName).toHaveBeenCalledWith(RoleName.TEACHER); }); }); + + describe('findCourseById', () => { + const setup = () => { + const course = courseFactory.buildWithId(); + courseService.findById.mockResolvedValue(course); + return { course }; + }; + + it('should return course by id', async () => { + const { course } = setup(); + const result = await uc.findCourseById(course.id); + + expect(result).toEqual(course); + expect(courseService.findById).toHaveBeenCalledWith(course.id); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/uc/course.uc.ts b/apps/server/src/modules/learnroom/uc/course.uc.ts index ed7abf98a1d..5f562c229e5 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.ts @@ -30,4 +30,8 @@ export class CourseUc { return role.permissions ?? []; } + + public async findCourseById(courseId: EntityId): Promise { + return this.courseService.findById(courseId); + } } diff --git a/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts b/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts index c8baef308b5..5fbbfce8ace 100644 --- a/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts +++ b/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts @@ -1,5 +1,5 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { AdminApiSchoolUc } from '../uc/admin-api-schools.uc'; import { AdminApiSchoolMapper } from './admin-api-schools.mapper'; @@ -7,7 +7,7 @@ import { AdminApiSchoolCreateBodyParams } from './dto/request/admin-api-school-c import { AdminApiSchoolCreateResponseDto } from './dto/response/admin-api-school-create.response.dto'; @ApiTags('AdminSchool') -@UseGuards(AuthGuard('api-key')) +@UseGuards(ApiKeyGuard) @Controller('admin/schools') export class AdminApiSchoolsController { constructor(private readonly uc: AdminApiSchoolUc) {} diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts index 45b209ae7dc..a59f907583e 100644 --- a/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts @@ -1,10 +1,10 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity'; import { TestApiClient, federalStateFactory, schoolYearFactory } from '@shared/testing'; import { AdminApiServerTestModule } from '@src/modules/server/admin-api.server.module'; -import { AuthGuard } from '@nestjs/passport'; import { AdminApiSchoolCreateResponseDto } from '../dto/response/admin-api-school-create.response.dto'; const baseRouteName = '/admin/schools'; @@ -19,7 +19,7 @@ describe('Admin API - Schools (API)', () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/legacy-school/controller/school.controller.ts b/apps/server/src/modules/legacy-school/controller/school.controller.ts index 50c6c0773ef..26672f40ca8 100644 --- a/apps/server/src/modules/legacy-school/controller/school.controller.ts +++ b/apps/server/src/modules/legacy-school/controller/school.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiBody, @@ -26,7 +26,7 @@ import { SchoolSystemOptionsMapper } from './school-system-options.mapper'; @ApiTags('School') @Controller('schools') -@Authenticate('jwt') +@JwtAuthentication() export class SchoolController { constructor(private readonly schoolSystemOptionsUc: SchoolSystemOptionsUc) {} diff --git a/apps/server/src/modules/lesson/controller/lesson.controller.ts b/apps/server/src/modules/lesson/controller/lesson.controller.ts index 1cc28c71e2e..48d0ea0a952 100644 --- a/apps/server/src/modules/lesson/controller/lesson.controller.ts +++ b/apps/server/src/modules/lesson/controller/lesson.controller.ts @@ -1,12 +1,12 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Delete, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { LessonUC } from '../uc'; -import { LessonUrlParams, LessonsUrlParams, LessonMetadataListResponse, LessonResponse } from './dto'; +import { LessonMetadataListResponse, LessonResponse, LessonUrlParams, LessonsUrlParams } from './dto'; import { LessonMapper } from './mapper/lesson.mapper'; @ApiTags('Lesson') -@Authenticate('jwt') +@JwtAuthentication() @Controller('lessons') export class LessonController { constructor(private readonly lessonUC: LessonUC) {} diff --git a/apps/server/src/modules/me/api/me.controller.ts b/apps/server/src/modules/me/api/me.controller.ts index f9cf53b0ba9..1e4e6c09f57 100644 --- a/apps/server/src/modules/me/api/me.controller.ts +++ b/apps/server/src/modules/me/api/me.controller.ts @@ -1,11 +1,11 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MeResponse } from './dto'; import { MeUc } from './me.uc'; @ApiTags('Me') -@Authenticate('jwt') +@JwtAuthentication() @Controller('me') export class MeController { constructor(private readonly meUc: MeUc) {} diff --git a/apps/server/src/modules/me/me-api.module.ts b/apps/server/src/modules/me/me-api.module.ts index d843867d704..86917c680d6 100644 --- a/apps/server/src/modules/me/me-api.module.ts +++ b/apps/server/src/modules/me/me-api.module.ts @@ -1,11 +1,10 @@ -import { AuthenticationModule } from '@modules/authentication'; import { SchoolModule } from '@modules/school'; import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { MeController, MeUc } from './api'; @Module({ - imports: [SchoolModule, UserModule, AuthenticationModule], + imports: [SchoolModule, UserModule], controllers: [MeController], providers: [MeUc], }) diff --git a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts index 79f798c9e29..379258c6592 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MetaTagExtractorUc } from '../uc'; @@ -6,7 +6,7 @@ import { MetaTagExtractorResponse } from './dto'; import { GetMetaTagDataBody } from './post-link-url.body.params'; @ApiTags('Meta Tag Extractor') -@Authenticate('jwt') +@JwtAuthentication() @Controller('meta-tag-extractor') export class MetaTagExtractorController { constructor(private readonly metaTagExtractorUc: MetaTagExtractorUc) {} diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts index b49efcc5cd3..2627e100245 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts @@ -1,5 +1,4 @@ import { ConsoleWriterModule } from '@infra/console'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { BoardModule } from '@modules/board'; import { LearnroomModule } from '@modules/learnroom'; import { LessonModule } from '@modules/lesson'; @@ -17,7 +16,6 @@ import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } f @Module({ imports: [ - AuthenticationModule, BoardModule, ConsoleWriterModule, HttpModule, diff --git a/apps/server/src/modules/news/controller/api-test/news.api.spec.ts b/apps/server/src/modules/news/controller/api-test/news.api.spec.ts index 0a951c69038..db4c401a6cc 100644 --- a/apps/server/src/modules/news/controller/api-test/news.api.spec.ts +++ b/apps/server/src/modules/news/controller/api-test/news.api.spec.ts @@ -1,5 +1,5 @@ +import { JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { FeathersAuthorizationService } from '@modules/authorization'; import { CreateNewsParams, NewsListResponse, NewsResponse, UpdateNewsParams } from '@modules/news/controller/dto'; import { ServerTestModule } from '@modules/server/server.module'; diff --git a/apps/server/src/modules/news/controller/news.controller.ts b/apps/server/src/modules/news/controller/news.controller.ts index c2cf6ba4e98..2bc2b716377 100644 --- a/apps/server/src/modules/news/controller/news.controller.ts +++ b/apps/server/src/modules/news/controller/news.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller'; @@ -14,7 +14,7 @@ import { } from './dto'; @ApiTags('News') -@Authenticate('jwt') +@JwtAuthentication() @Controller('news') export class NewsController { constructor(private readonly newsUc: NewsUc) {} diff --git a/apps/server/src/modules/news/controller/team-news.controller.ts b/apps/server/src/modules/news/controller/team-news.controller.ts index ac70748e439..0810eb5048b 100644 --- a/apps/server/src/modules/news/controller/team-news.controller.ts +++ b/apps/server/src/modules/news/controller/team-news.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller'; @@ -7,7 +7,7 @@ import { NewsUc } from '../uc'; import { FilterNewsParams, NewsListResponse, TeamUrlParams } from './dto'; @ApiTags('News') -@Authenticate('jwt') +@JwtAuthentication() @Controller('team') export class TeamNewsController { constructor(private readonly newsUc: NewsUc) {} diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts index 3c53d9c3bac..21b51a1593b 100644 --- a/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { @@ -42,7 +42,7 @@ export class OauthProviderController { private readonly oauthProviderLoginFlowUc: OauthProviderLoginFlowUc ) {} - @Authenticate('jwt') + @JwtAuthentication() @Get('clients/:id') public async getOAuth2Client( @CurrentUser() currentUser: ICurrentUser, @@ -55,7 +55,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Get('clients') public async listOAuth2Clients( @CurrentUser() currentUser: ICurrentUser, @@ -76,7 +76,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Post('clients') public async createOAuth2Client( @CurrentUser() currentUser: ICurrentUser, @@ -89,7 +89,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Put('clients/:id') public async updateOAuth2Client( @CurrentUser() currentUser: ICurrentUser, @@ -104,7 +104,7 @@ export class OauthProviderController { } @HttpCode(HttpStatus.NO_CONTENT) - @Authenticate('jwt') + @JwtAuthentication() @Delete('clients/:id') public deleteOAuth2Client(@CurrentUser() currentUser: ICurrentUser, @Param() params: IdParams): Promise { const promise: Promise = this.crudUc.deleteOAuth2Client(currentUser.userId, params.id); @@ -121,7 +121,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Patch('loginRequest/:challenge') public async patchLoginRequest( @Param() params: ChallengeParams, @@ -141,7 +141,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Patch('logoutRequest/:challenge') public async acceptLogoutRequest(@Param() params: ChallengeParams): Promise { const redirect: ProviderRedirectResponse = await this.logoutFlowUc.logoutFlow(params.challenge); @@ -151,7 +151,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Get('consentRequest/:challenge') public async getConsentRequest(@Param() params: ChallengeParams): Promise { const consentRequest: ProviderConsentResponse = await this.consentFlowUc.getConsentRequest(params.challenge); @@ -161,7 +161,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Patch('consentRequest/:challenge') public async patchConsentRequest( @Param() params: ChallengeParams, @@ -181,7 +181,7 @@ export class OauthProviderController { return response; } - @Authenticate('jwt') + @JwtAuthentication() @Get('auth/sessions/consent') public async listConsentSessions(@CurrentUser() currentUser: ICurrentUser): Promise { const sessions: ProviderConsentSessionResponse[] = await this.oauthProviderUc.listConsentSessions( @@ -196,7 +196,7 @@ export class OauthProviderController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Delete('auth/sessions/consent') public revokeConsentSession( @CurrentUser() currentUser: ICurrentUser, diff --git a/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts b/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts index a78b365d126..987cf7f0016 100644 --- a/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts @@ -26,7 +26,7 @@ describe(HydraOauthFailedLoggableException.name, () => { expect(message).toEqual({ type: 'HYDRA_OAUTH_FAILED', - message: axiosError.message, + message: 'message: Bad Request code: 400', stack: axiosError.stack, data: JSON.stringify(error), }); diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts index 3364f9c6267..33750bbef6d 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts @@ -3,8 +3,8 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { HydraOauthUc } from '@modules/oauth/uc/hydra-oauth.uc'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { currentUserFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { currentUserFactory } from '@modules/authentication/testing'; import { Request } from 'express'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; import { OauthSSOController } from './oauth-sso.controller'; 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 cd6d68b77f0..6d79b37bf6d 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param, Query, Req, UnauthorizedException } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { LegacyLogger } from '@src/core/logger'; @@ -19,7 +19,7 @@ export class OauthSSOController { } @Get('hydra/:oauthClientId') - @Authenticate('jwt') + @JwtAuthentication() async getHydraOauthToken( @Query() query: StatelessAuthorizationParams, @Param('oauthClientId') oauthClientId: string @@ -29,7 +29,7 @@ export class OauthSSOController { } @Get('auth/:oauthClientId') - @Authenticate('jwt') + @JwtAuthentication() async requestAuthToken( @CurrentUser() currentUser: ICurrentUser, @Req() req: Request, diff --git a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts index 6716175bdbe..c63db6d9124 100644 --- a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts @@ -25,7 +25,7 @@ describe(TokenRequestLoggableException.name, () => { expect(logMessage).toStrictEqual({ type: 'OAUTH_TOKEN_REQUEST_ERROR', - message: axiosError.message, + message: 'message: Bad Request code: 400', data: JSON.stringify(error), stack: axiosError.stack, }); diff --git a/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts index 4eab278fb89..ad40e5b7ed3 100644 --- a/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts +++ b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param } from '@nestjs/common'; import { ApiForbiddenResponse, @@ -14,7 +14,7 @@ import { PseudonymResponse } from './dto'; import { PseudonymParams } from './dto/pseudonym-params'; @ApiTags('Pseudonym') -@Authenticate('jwt') +@JwtAuthentication() @Controller('pseudonyms') export class PseudonymController { constructor(private readonly pseudonymUc: PseudonymUc) {} diff --git a/apps/server/src/modules/registration-pin/admin-api-registration-pin.module.ts b/apps/server/src/modules/registration-pin/admin-api-registration-pin.module.ts new file mode 100644 index 00000000000..b398c919fed --- /dev/null +++ b/apps/server/src/modules/registration-pin/admin-api-registration-pin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AdminApiRegistrationPinController, RegistrationPinUc } from './api'; +import { RegistrationPinModule } from './registration-pin.module'; + +@Module({ + imports: [RegistrationPinModule], + controllers: [AdminApiRegistrationPinController], + providers: [RegistrationPinUc], +}) +export class AdminApiRegistrationPinModule {} diff --git a/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.spec.ts b/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.spec.ts new file mode 100644 index 00000000000..2a540d2f920 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.spec.ts @@ -0,0 +1,54 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminApiRegistrationPinController } from './admin-api-registration-pin.controller'; +import { RegistrationPinUc } from './registration-pin.uc'; + +describe('AdminApiRegistrationPinController', () => { + let module: TestingModule; + let sut: AdminApiRegistrationPinController; + let registrationPinUcMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [AdminApiRegistrationPinController], + providers: [ + { + provide: RegistrationPinUc, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(AdminApiRegistrationPinController); + registrationPinUcMock = module.get(RegistrationPinUc); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('getRegistrationPinsForEmail', () => { + describe('when searching for registration pins by email', () => { + const setup = () => { + registrationPinUcMock.findForEmail.mockResolvedValue([]); + }; + + it('should return found registration pins', async () => { + setup(); + + const result = await sut.getRegistrationPinsForEmail(faker.internet.email()); + + expect(result).toEqual([]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.ts b/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.ts new file mode 100644 index 00000000000..6abb0ff6d7c --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/admin-api-registration-pin.controller.ts @@ -0,0 +1,19 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { GetRegistrationPinResponse } from './dto'; +import { RegistrationPinUc } from './registration-pin.uc'; + +@ApiTags('AdminRegistrationPin') +@UseGuards(ApiKeyGuard) +@Controller('registration-pin') +export class AdminApiRegistrationPinController { + constructor(private readonly uc: RegistrationPinUc) {} + + @Get(':email') + async getRegistrationPinsForEmail(@Param('email') email: string): Promise { + const response = await this.uc.findForEmail(email); + + return response; + } +} diff --git a/apps/server/src/modules/registration-pin/api/dto/index.ts b/apps/server/src/modules/registration-pin/api/dto/index.ts new file mode 100644 index 00000000000..698cf2b864e --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/dto/index.ts @@ -0,0 +1 @@ +export * from './response/get-registration-pin.response'; diff --git a/apps/server/src/modules/registration-pin/api/dto/response/get-registration-pin.response.ts b/apps/server/src/modules/registration-pin/api/dto/response/get-registration-pin.response.ts new file mode 100644 index 00000000000..fc6790908f3 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/dto/response/get-registration-pin.response.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetRegistrationPinResponse { + @ApiProperty() + public username: string; + + @ApiProperty() + registrationPin: string; + + @ApiProperty() + public verified: boolean; + + @ApiProperty() + public createdAt: Date; + + constructor(props: Readonly) { + this.username = props.username; + this.registrationPin = props.registrationPin; + this.verified = props.verified; + this.createdAt = props.createdAt; + } +} diff --git a/apps/server/src/modules/registration-pin/api/index.ts b/apps/server/src/modules/registration-pin/api/index.ts new file mode 100644 index 00000000000..1b067e83d8d --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/index.ts @@ -0,0 +1,2 @@ +export * from './admin-api-registration-pin.controller'; +export * from './registration-pin.uc'; diff --git a/apps/server/src/modules/registration-pin/api/mapper/index.ts b/apps/server/src/modules/registration-pin/api/mapper/index.ts new file mode 100644 index 00000000000..b7b9b9abd14 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/mapper/index.ts @@ -0,0 +1 @@ +export * from './registration-pin-response.mapper'; diff --git a/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.spec.ts b/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.spec.ts new file mode 100644 index 00000000000..2a0c85dccb1 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.spec.ts @@ -0,0 +1,22 @@ +import { registrationPinEntityFactory } from '../../entity/testing'; +import { GetRegistrationPinResponse } from '../dto'; +import { RegistrationPinResponseMapper } from './registration-pin-response.mapper'; + +describe('RegistrationPinResponseMapper', () => { + describe('mapRegistrationPinDoToRegistrationPinResponse', () => { + describe('when mapping DO to response', () => { + it('should map correctly', () => { + const registrationPin = registrationPinEntityFactory.build(); + + const result = RegistrationPinResponseMapper.mapRegistrationPinDoToRegistrationPinResponse(registrationPin); + + expect(result).toEqual({ + username: registrationPin.email, + registrationPin: registrationPin.pin, + verified: registrationPin.verified, + createdAt: registrationPin.createdAt, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.ts b/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.ts new file mode 100644 index 00000000000..0bd8ca35bad --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/mapper/registration-pin-response.mapper.ts @@ -0,0 +1,15 @@ +import { RegistrationPinEntity } from '../../entity'; +import { GetRegistrationPinResponse } from '../dto'; + +export class RegistrationPinResponseMapper { + public static mapRegistrationPinDoToRegistrationPinResponse( + registrationPin: RegistrationPinEntity + ): GetRegistrationPinResponse { + return new GetRegistrationPinResponse({ + username: registrationPin.email, + registrationPin: registrationPin.pin, + verified: registrationPin.verified, + createdAt: registrationPin.createdAt, + }); + } +} diff --git a/apps/server/src/modules/registration-pin/api/registration-pin.uc.spec.ts b/apps/server/src/modules/registration-pin/api/registration-pin.uc.spec.ts new file mode 100644 index 00000000000..bd0353f2b74 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/registration-pin.uc.spec.ts @@ -0,0 +1,57 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { registrationPinEntityFactory } from '../entity/testing'; +import { RegistrationPinService } from '../service'; +import { RegistrationPinUc } from './registration-pin.uc'; + +describe('RegistrationPinUc', () => { + let module: TestingModule; + let sut: RegistrationPinUc; + let registrationPinServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RegistrationPinUc, + { + provide: RegistrationPinService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(RegistrationPinUc); + registrationPinServiceMock = module.get(RegistrationPinService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('findForEmail', () => { + describe('when finding registration pins for email', () => { + const setup = () => { + const registrationPin = registrationPinEntityFactory.build(); + + registrationPinServiceMock.findByEmail.mockResolvedValue([registrationPin]); + }; + + it('should return found registration pins', async () => { + setup(); + + const result = await sut.findForEmail(faker.internet.email()); + + expect(result).toHaveLength(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/api/registration-pin.uc.ts b/apps/server/src/modules/registration-pin/api/registration-pin.uc.ts new file mode 100644 index 00000000000..22c95f811a4 --- /dev/null +++ b/apps/server/src/modules/registration-pin/api/registration-pin.uc.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { RegistrationPinService } from '../service'; +import { GetRegistrationPinResponse } from './dto'; +import { RegistrationPinResponseMapper } from './mapper'; + +@Injectable() +export class RegistrationPinUc { + constructor(private readonly registrationPinService: RegistrationPinService) {} + + public async findForEmail(email: string): Promise { + const registrationPin = await this.registrationPinService.findByEmail(email); + const response = registrationPin.map((pin) => + RegistrationPinResponseMapper.mapRegistrationPinDoToRegistrationPinResponse(pin) + ); + + return response; + } +} diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts index 523f821000f..10f2d5231fc 100644 --- a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts @@ -1,16 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { setupEntities, userDoFactory } from '@shared/testing'; -import { Logger } from '@src/core/logger'; import { + DeletionErrorLoggableException, DomainDeletionReportBuilder, DomainName, DomainOperationReportBuilder, OperationType, - DeletionErrorLoggableException, } from '@modules/deletion'; -import { registrationPinEntityFactory } from '../entity/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { RegistrationPinService } from '.'; +import { registrationPinEntityFactory } from '../entity/testing'; import { RegistrationPinRepo } from '../repo'; describe(RegistrationPinService.name, () => { @@ -133,5 +133,27 @@ describe(RegistrationPinService.name, () => { await expect(service.deleteUserData(user.email)).rejects.toThrowError(expectedError); }); }); + + describe('findByEmail', () => { + describe('when finding registration pins by email', () => { + const setup = () => { + const registrationPin = registrationPinEntityFactory.buildWithId(); + + registrationPinRepo.findAllByEmail.mockResolvedValueOnce([[registrationPin], 1]); + + return { registrationPin }; + }; + + it('should return found registration pins', async () => { + const { registrationPin } = setup(); + + registrationPinRepo.findAllByEmail.mockResolvedValueOnce([[registrationPin], 1]); + + const result = await service.findByEmail(registrationPin.email); + + expect(result).toEqual([registrationPin]); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts index bf1a4a661a9..728eaae33e3 100644 --- a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts @@ -1,17 +1,17 @@ -import { Injectable } from '@nestjs/common'; -import { Logger } from '@src/core/logger'; -import { EntityId } from '@shared/domain/types'; import { - DeletionService, - DomainDeletionReport, DataDeletionDomainOperationLoggable, - DomainName, DeletionErrorLoggableException, + DeletionService, + DomainDeletionReport, DomainDeletionReportBuilder, + DomainName, DomainOperationReportBuilder, OperationType, StatusModel, } from '@modules/deletion'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; import { RegistrationPinEntity } from '../entity'; import { RegistrationPinRepo } from '../repo'; @@ -21,6 +21,12 @@ export class RegistrationPinService implements DeletionService { this.logger.setContext(RegistrationPinService.name); } + public async findByEmail(email: string): Promise { + const [registrationPins] = await this.registrationPinRepo.findAllByEmail(email); + + return registrationPins; + } + public async deleteUserData(email: string): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( diff --git a/apps/server/src/modules/school/api/school.controller.ts b/apps/server/src/modules/school/api/school.controller.ts index c88b2c9f032..5d4ad844d7a 100644 --- a/apps/server/src/modules/school/api/school.controller.ts +++ b/apps/server/src/modules/school/api/school.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; @@ -14,7 +14,7 @@ export class SchoolController { constructor(private readonly schoolUc: SchoolUc) {} @Get('/id/:schoolId') - @Authenticate('jwt') + @JwtAuthentication() public async getSchoolById( @Param() urlParams: SchoolUrlParams, @CurrentUser() user: ICurrentUser @@ -25,7 +25,7 @@ export class SchoolController { } @Get('/list-for-external-invite') - @Authenticate('jwt') + @JwtAuthentication() public async getSchoolListForExternalInvite( @Query() query: SchoolQueryParams, @CurrentUser() user: ICurrentUser @@ -54,7 +54,7 @@ export class SchoolController { @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) - @Authenticate('jwt') + @JwtAuthentication() @Get('/:schoolId/systems') public async getSchoolSystems( @Param() urlParams: SchoolUrlParams, @@ -72,7 +72,7 @@ export class SchoolController { @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) @Patch('/:schoolId') - @Authenticate('jwt') + @JwtAuthentication() public async updateSchool( @Param() urlParams: SchoolUrlParams, @Body() body: SchoolUpdateBodyParams, @@ -84,7 +84,7 @@ export class SchoolController { } @Patch('/:schoolId/system/:systemId/remove') - @Authenticate('jwt') + @JwtAuthentication() public async removeSystemFromSchool( @Param() urlParams: SchoolRemoveSystemUrlParams, @CurrentUser() user: ICurrentUser diff --git a/apps/server/src/modules/school/api/test/school-patch.api.spec.ts b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts index eb255861f24..acfa1e83a2c 100644 --- a/apps/server/src/modules/school/api/test/school-patch.api.spec.ts +++ b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts @@ -210,9 +210,7 @@ describe('School Controller (API)', () => { expect.objectContaining({ validationErrors: [ { - errors: [ - 'each value in features must be one of the following values: rocketChat, videoconference, nextcloud, studentVisibility, ldapUniventionMigrationSchool, oauthProvisioningEnabled, showOutdatedUsers, enableLdapSyncDuringMigration', - ], + errors: [expect.stringContaining('each value in features must be one of the following values:')], field: ['features'], }, ], diff --git a/apps/server/src/modules/server/admin-api.server.module.ts b/apps/server/src/modules/server/admin-api.server.module.ts index 9207ec6465f..acd0a8d286e 100644 --- a/apps/server/src/modules/server/admin-api.server.module.ts +++ b/apps/server/src/modules/server/admin-api.server.module.ts @@ -1,18 +1,21 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthGuardModule } from '@infra/auth-guard'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { DeletionApiModule } from '@modules/deletion/deletion-api.module'; import { FileEntity } from '@modules/files/entity'; import { LegacySchoolAdminApiModule } from '@modules/legacy-school/legacy-school-admin.api-module'; +import { ToolAdminApiModule } from '@modules/tool/tool-admin-api.module'; import { UserAdminApiModule } from '@modules/user/user-admin-api.module'; import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { CqrsModule } from '@nestjs/cqrs'; import { ALL_ENTITIES } from '@shared/domain/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@src/infra/database'; import { EtherpadClientModule } from '@src/infra/etherpad-client'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@src/infra/rabbitmq'; +import { AdminApiRegistrationPinModule } from '../registration-pin/admin-api-registration-pin.module'; import { serverConfig } from './server.config'; import { defaultMikroOrmOptions } from './server.module'; @@ -21,10 +24,13 @@ const serverModules = [ DeletionApiModule, LegacySchoolAdminApiModule, UserAdminApiModule, + AdminApiRegistrationPinModule, + ToolAdminApiModule, EtherpadClientModule.register({ apiKey: Configuration.has('ETHERPAD__API_KEY') ? (Configuration.get('ETHERPAD__API_KEY') as string) : undefined, basePath: Configuration.has('ETHERPAD__URI') ? (Configuration.get('ETHERPAD__URI') as string) : undefined, }), + AuthGuardModule, ]; @Module({ diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 87fa2771d38..2642d2d6761 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -203,6 +203,9 @@ export class ConfigResponse { @ApiProperty() SC_TITLE: string; + @ApiProperty() + TRAINING_URL: string; + @ApiProperty() FEATURE_MEDIA_SHELF_ENABLED: boolean; @@ -212,6 +215,12 @@ export class ConfigResponse { @ApiProperty() FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED: boolean; + @ApiProperty() + FEATURE_AI_TUTOR_ENABLED: boolean; + + @ApiProperty() + FEATURE_ROOMS_ENABLED: boolean; + constructor(config: ServerConfig) { this.ACCESSIBILITY_REPORT_EMAIL = config.ACCESSIBILITY_REPORT_EMAIL; this.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN = config.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN; @@ -260,6 +269,7 @@ export class ConfigResponse { this.DOCUMENT_BASE_DIR = config.DOCUMENT_BASE_DIR; this.SC_THEME = config.SC_THEME; this.SC_TITLE = config.SC_TITLE; + this.TRAINING_URL = config.TRAINING_URL; this.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED = config.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED; this.MIGRATION_END_GRACE_PERIOD_MS = config.MIGRATION_END_GRACE_PERIOD_MS; @@ -281,5 +291,7 @@ export class ConfigResponse { this.FEATURE_MEDIA_SHELF_ENABLED = config.FEATURE_MEDIA_SHELF_ENABLED; this.BOARD_COLLABORATION_URI = config.BOARD_COLLABORATION_URI; this.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED = config.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED; + this.FEATURE_AI_TUTOR_ENABLED = config.FEATURE_AI_TUTOR_ENABLED; + this.FEATURE_ROOMS_ENABLED = config.FEATURE_ROOMS_ENABLED; } } diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index a0a844e3f99..58fe1d2f799 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -88,6 +88,7 @@ describe('Server Controller (API)', () => { 'ROCKETCHAT_SERVICE_ENABLED', 'SC_THEME', 'SC_TITLE', + 'TRAINING_URL', 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE', 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', 'TEACHER_STUDENT_VISIBILITY__IS_VISIBLE', @@ -98,6 +99,8 @@ describe('Server Controller (API)', () => { 'FEATURE_MEDIA_SHELF_ENABLED', 'BOARD_COLLABORATION_URI', 'FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED', + 'FEATURE_AI_TUTOR_ENABLED', + 'FEATURE_ROOMS_ENABLED', ]; expect(response.status).toEqual(HttpStatus.OK); diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index d4d813e3836..147ad77b200 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -1,9 +1,11 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { XApiKeyConfig } from '@infra/auth-guard'; import type { IdentityManagementConfig } from '@infra/identity-management'; +import type { MailConfig } from '@infra/mail/interfaces/mail-config'; import type { SchulconnexClientConfig } from '@infra/schulconnex-client'; import type { AccountConfig } from '@modules/account'; import { AlertConfig } from '@modules/alert'; -import type { AuthenticationConfig, XApiKeyConfig } from '@modules/authentication'; +import type { AuthenticationConfig } from '@modules/authentication'; import type { BoardConfig } from '@modules/board'; import type { MediaBoardConfig } from '@modules/board/media-board.config'; import type { CollaborativeTextEditorConfig } from '@modules/collaborative-text-editor'; @@ -24,7 +26,6 @@ import { VideoConferenceConfig } from '@modules/video-conference'; import { LanguageType } from '@shared/domain/interface'; import { SchulcloudTheme } from '@shared/domain/types'; import type { CoreModuleConfig } from '@src/core'; -import type { MailConfig } from '@src/infra/mail/interfaces/mail-config'; import { BbbConfig } from '../video-conference/bbb'; import { Timezone } from './types/timezone.enum'; @@ -99,6 +100,7 @@ export interface ServerConfig DOCUMENT_BASE_DIR: string; SC_THEME: SchulcloudTheme; SC_TITLE: string; + TRAINING_URL: string; FEATURE_SHOW_OUTDATED_USERS: boolean; FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: boolean; FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: boolean; @@ -119,6 +121,8 @@ export interface ServerConfig SCHULCONNEX_CLIENT__TOKEN_ENDPOINT: string | undefined; SCHULCONNEX_CLIENT__CLIENT_ID: string | undefined; SCHULCONNEX_CLIENT__CLIENT_SECRET: string | undefined; + FEATURE_AI_TUTOR_ENABLED: boolean; + FEATURE_ROOMS_ENABLED: boolean; } const config: ServerConfig = { @@ -172,6 +176,7 @@ const config: ServerConfig = { SC_THEME: Configuration.get('SC_THEME') as SchulcloudTheme, SC_TITLE: Configuration.get('SC_TITLE') as string, SC_DOMAIN: Configuration.get('SC_DOMAIN') as string, + TRAINING_URL: Configuration.get('TRAINING_URL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, @@ -283,6 +288,8 @@ const config: ServerConfig = { 'FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION' ) as boolean, FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, + FEATURE_AI_TUTOR_ENABLED: Configuration.get('FEATURE_AI_TUTOR_ENABLED') as boolean, + FEATURE_ROOMS_ENABLED: Configuration.get('FEATURE_ROOMS_ENABLED') as boolean, }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 2c7da2546bf..f82d6366fad 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -1,4 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthGuardModule } from '@infra/auth-guard'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { MailModule } from '@infra/mail'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; @@ -50,6 +51,7 @@ const serverModules = [ ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), CoreModule, AuthenticationApiModule, + AuthGuardModule, AuthorizationReferenceApiModule, AccountApiModule, CollaborativeStorageModule, diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts index d6b00ae9b78..68423d68574 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts @@ -1,7 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index 7d840d85def..9386b965857 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -1,7 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@modules/copy-helper'; import { ServerTestModule } from '@modules/server'; import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; diff --git a/apps/server/src/modules/sharing/controller/share-token.controller.spec.ts b/apps/server/src/modules/sharing/controller/share-token.controller.spec.ts index d37b8b435ff..cdd5f009ca2 100644 --- a/apps/server/src/modules/sharing/controller/share-token.controller.spec.ts +++ b/apps/server/src/modules/sharing/controller/share-token.controller.spec.ts @@ -1,8 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ICurrentUser } from '@modules/authentication'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { Test, TestingModule } from '@nestjs/testing'; -import { courseFactory, setupEntities, shareTokenFactory } from '@shared/testing'; +import { courseFactory, currentUserFactory, setupEntities, shareTokenFactory } from '@shared/testing'; import { ShareTokenParentType } from '../domainobject/share-token.do'; import { ShareTokenUC } from '../uc'; import { ShareTokenInfoDto } from '../uc/dto'; @@ -39,7 +38,7 @@ describe('ShareTokenController', () => { describe('creating a token', () => { const setup = () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const shareToken = shareTokenFactory.build({ token: 'ctuW1FG0RsTo' }); uc.createShareToken.mockResolvedValue(shareToken); const body = { @@ -84,7 +83,7 @@ describe('ShareTokenController', () => { describe('looking up a token', () => { it('should call the use case', async () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const token = 'ctuW1FG0RsTo'; await controller.lookupShareToken(currentUser, { token }); @@ -93,7 +92,7 @@ describe('ShareTokenController', () => { }); it('should return the token data', async () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const shareTokenInfo: ShareTokenInfoDto = { token: 'ctuW1FG0RsTo', parentType: ShareTokenParentType.Course, @@ -110,7 +109,7 @@ describe('ShareTokenController', () => { describe('importing a share token', () => { const setup = () => { - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const token = 'ctuW1FG0RsTo'; const course = courseFactory.buildWithId(); const status: CopyStatus = { diff --git a/apps/server/src/modules/sharing/controller/share-token.controller.ts b/apps/server/src/modules/sharing/controller/share-token.controller.ts index 63c88fdded6..5320c5578f9 100644 --- a/apps/server/src/modules/sharing/controller/share-token.controller.ts +++ b/apps/server/src/modules/sharing/controller/share-token.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; import { Body, @@ -24,7 +24,7 @@ import { } from './dto'; @ApiTags('ShareToken') -@Authenticate('jwt') +@JwtAuthentication() @Controller('sharetoken') export class ShareTokenController { constructor(private readonly shareTokenUC: ShareTokenUC) {} diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index ab115a0efdd..440a460502a 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; import { ApiForbiddenResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { System } from '../domain'; @@ -41,7 +41,7 @@ export class SystemController { return mapped; } - @Authenticate('jwt') + @JwtAuthentication() @Delete(':systemId') @ApiForbiddenResponse() @ApiUnauthorizedResponse() diff --git a/apps/server/src/modules/task/controller/api-test/submission.api.spec.ts b/apps/server/src/modules/task/controller/api-test/submission.api.spec.ts index 5df2785c2db..4a91e22befc 100644 --- a/apps/server/src/modules/task/controller/api-test/submission.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/submission.api.spec.ts @@ -1,7 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ServerTestModule } from '@modules/server/server.module'; import { SubmissionStatusListResponse } from '@modules/task/controller/dto/submission.response'; diff --git a/apps/server/src/modules/task/controller/api-test/task-copy-timeout.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-copy-timeout.api.spec.ts index d5546c435da..93e91f3d90c 100644 --- a/apps/server/src/modules/task/controller/api-test/task-copy-timeout.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-copy-timeout.api.spec.ts @@ -1,9 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; diff --git a/apps/server/src/modules/task/controller/api-test/task-finished.api.spec.ts b/apps/server/src/modules/task/controller/api-test/task-finished.api.spec.ts index 7c6d543c3ec..e58673aad5e 100644 --- a/apps/server/src/modules/task/controller/api-test/task-finished.api.spec.ts +++ b/apps/server/src/modules/task/controller/api-test/task-finished.api.spec.ts @@ -1,6 +1,5 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { TaskListResponse } from '@modules/task/controller/dto'; import { ExecutionContext, INestApplication } from '@nestjs/common'; diff --git a/apps/server/src/modules/task/controller/submission.controller.ts b/apps/server/src/modules/task/controller/submission.controller.ts index 48f0899b07e..8eb5c91f270 100644 --- a/apps/server/src/modules/task/controller/submission.controller.ts +++ b/apps/server/src/modules/task/controller/submission.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Delete, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SubmissionMapper } from '../mapper'; @@ -6,7 +6,7 @@ import { SubmissionUc } from '../uc'; import { SubmissionStatusListResponse, SubmissionUrlParams, TaskUrlParams } from './dto'; @ApiTags('Submission') -@Authenticate('jwt') +@JwtAuthentication() @Controller('submissions') export class SubmissionController { constructor(private readonly submissionUc: SubmissionUc) {} diff --git a/apps/server/src/modules/task/controller/task.controller.spec.ts b/apps/server/src/modules/task/controller/task.controller.spec.ts index b76bb66d4da..a98f2ae42f0 100644 --- a/apps/server/src/modules/task/controller/task.controller.spec.ts +++ b/apps/server/src/modules/task/controller/task.controller.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; -import { ICurrentUser } from '@modules/authentication'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { CopyApiResponse } from '@modules/copy-helper/dto/copy.response'; import { Test, TestingModule } from '@nestjs/testing'; +import { currentUserFactory } from '@shared/testing'; import { TaskCopyUC, TaskUC } from '../uc'; import { TaskController } from './task.controller'; @@ -42,7 +42,7 @@ describe('TaskController', () => { describe('when task should be copied via API call', () => { const setup = () => { // todo: why not use builder instead of as - const currentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const ucResult = { title: 'example title', type: 'TASK' as CopyElementType, @@ -52,10 +52,11 @@ describe('TaskController', () => { const ucSpy = jest.spyOn(uc, 'copyTask').mockImplementation(() => Promise.resolve(ucResult)); return { currentUser, ucSpy }; }; + it('should call uc with two parentIds', async () => { const { currentUser, ucSpy } = setup(); await controller.copyTask(currentUser, { taskId: 'taskId' }, { courseId: 'id', lessonId: 'anotherId' }); - expect(ucSpy).toHaveBeenCalledWith('userId', 'taskId', { + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'taskId', { courseId: 'id', lessonId: 'anotherId', userId: currentUser.userId, @@ -65,7 +66,10 @@ describe('TaskController', () => { it('should call uc with one parentId', async () => { const { currentUser, ucSpy } = setup(); await controller.copyTask(currentUser, { taskId: 'taskId' }, { courseId: 'id' }); - expect(ucSpy).toHaveBeenCalledWith('userId', 'taskId', { courseId: 'id', userId: 'userId' }); + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'taskId', { + courseId: 'id', + userId: currentUser.userId, + }); }); it('should return result of correct type', async () => { diff --git a/apps/server/src/modules/task/controller/task.controller.ts b/apps/server/src/modules/task/controller/task.controller.ts index cb1bc1ba8b1..a99a584db2c 100644 --- a/apps/server/src/modules/task/controller/task.controller.ts +++ b/apps/server/src/modules/task/controller/task.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; @@ -11,7 +11,7 @@ import { TaskListResponse, TaskResponse, TaskUrlParams } from './dto'; import { TaskCopyApiParams } from './dto/task-copy.params'; @ApiTags('Task') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tasks') export class TaskController { constructor(private readonly taskUc: TaskUC, private readonly taskCopyUc: TaskCopyUC) {} diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts index c58dcf30600..2dde66d1b75 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts @@ -1,14 +1,14 @@ -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { ApiKeyGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; -import { courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; -import { Test, TestingModule } from '@nestjs/testing'; import { ServerTestModule } from '@modules/server'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AuthGuard } from '@nestjs/passport'; import { Request } from 'express'; -import { TldrawService } from '../../service'; import { TldrawController } from '..'; import { TldrawRepo } from '../../repo'; +import { TldrawService } from '../../service'; import { tldrawEntityFactory } from '../../testing'; const baseRouteName = '/tldraw-document'; @@ -24,7 +24,7 @@ describe('tldraw controller (api)', () => { controllers: [TldrawController], providers: [Logger, TldrawService, TldrawRepo], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts index 7ae5ece2048..dd32f289c2f 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -1,12 +1,12 @@ -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiKeyGuard } from '@infra/auth-guard'; import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; -import { AuthGuard } from '@nestjs/passport'; import { TldrawService } from '../service'; import { TldrawDeleteParams } from './tldraw.params'; @ApiTags('Tldraw Document') -@UseGuards(AuthGuard('api-key')) +@UseGuards(ApiKeyGuard) @Controller('tldraw-document') export class TldrawController { constructor(private readonly tldrawService: TldrawService) {} diff --git a/apps/server/src/modules/tldraw/tldraw-api.module.ts b/apps/server/src/modules/tldraw/tldraw-api.module.ts index fbba4bef505..238a8c605b3 100644 --- a/apps/server/src/modules/tldraw/tldraw-api.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-api.module.ts @@ -1,17 +1,16 @@ +import { AuthGuardModule } from '@infra/auth-guard'; +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 { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; -import { LoggerModule } from '@src/core/logger'; -import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { CoreModule } from '@src/core'; +import { LoggerModule } from '@src/core/logger'; import { config, TLDRAW_DB_URL } from './config'; -import { TldrawDrawing } from './entities'; import { TldrawController } from './controller'; -import { TldrawService } from './service'; +import { TldrawDrawing } from './entities'; import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; -// TODO must be fixed, direct import of a file from another module in not allowed -import { XApiKeyStrategy } from '../authentication/strategy/x-api-key.strategy'; +import { TldrawService } from './service'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -32,8 +31,9 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { entities: [TldrawDrawing], }), ConfigModule.forRoot(createConfigModuleOptions(config)), + AuthGuardModule, ], - providers: [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb, XApiKeyStrategy], + providers: [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb], controllers: [TldrawController], }) export class TldrawApiModule {} diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index a4d7ba2ede6..6f8702fe4b6 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -1,3 +1,4 @@ +import { AuthGuardModule } from '@infra/auth-guard'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { HttpModule } from '@nestjs/axios'; @@ -7,7 +8,7 @@ import { initialisePerformanceObserver } from '@shared/common/measure-utils'; import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger, LoggerModule } from '@src/core/logger'; -import { config, TldrawConfig, TLDRAW_DB_URL } from './config'; +import { config, TLDRAW_DB_URL, TldrawConfig } from './config'; import { TldrawWs } from './controller'; import { TldrawDrawing } from './entities'; import { MetricsService } from './metrics'; @@ -34,6 +35,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { entities: [TldrawDrawing], }), ConfigModule.forRoot(createConfigModuleOptions(config)), + AuthGuardModule, ], providers: [ TldrawWs, 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 535e97f448e..a48b814900f 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -1,27 +1,32 @@ import { BoardModule } from '@modules/board'; import { forwardRef, Module } from '@nestjs/common'; -import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { CqrsModule } from '@nestjs/cqrs'; +import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { SchoolModule } from '@src/modules/school'; -import { CommonToolService, CommonToolValidationService } from './service'; +import { CommonToolDeleteService, CommonToolService, CommonToolValidationService } from './service'; import { CommonToolMetadataService } from './service/common-tool-metadata.service'; @Module({ - imports: [LoggerModule, SchoolModule, forwardRef(() => BoardModule)], + imports: [LoggerModule, SchoolModule, forwardRef(() => BoardModule), CqrsModule], // TODO: make deletion of entities cascading, adjust ExternalToolService.deleteExternalTool and remove the repos from here providers: [ CommonToolService, CommonToolValidationService, + ExternalToolRepo, SchoolExternalToolRepo, ContextExternalToolRepo, CommonToolMetadataService, + CommonToolDeleteService, ], exports: [ CommonToolService, CommonToolValidationService, + ExternalToolRepo, SchoolExternalToolRepo, ContextExternalToolRepo, CommonToolMetadataService, + CommonToolDeleteService, ], }) export class CommonToolModule {} diff --git a/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts index 7165a6a5750..d3aa29c8258 100644 --- a/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts @@ -7,6 +7,7 @@ export enum CustomParameterType { AUTO_SCHOOLID = 'auto_schoolid', AUTO_SCHOOLNUMBER = 'auto_schoolnumber', AUTO_MEDIUMID = 'auto_mediumid', + AUTO_GROUP_EXTERNALUUID = 'auto_group_externaluuid', } export const autoParameters: CustomParameterType[] = [ @@ -15,4 +16,5 @@ export const autoParameters: CustomParameterType[] = [ CustomParameterType.AUTO_SCHOOLID, CustomParameterType.AUTO_SCHOOLNUMBER, CustomParameterType.AUTO_MEDIUMID, + CustomParameterType.AUTO_GROUP_EXTERNALUUID, ]; diff --git a/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts index 7771941ee9a..2f166134562 100644 --- a/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts @@ -7,4 +7,5 @@ export enum CustomParameterTypeParams { AUTO_SCHOOLID = 'auto_schoolid', AUTO_SCHOOLNUMBER = 'auto_schoolnumber', AUTO_MEDIUMID = 'auto_mediumid', + AUTO_GROUP_EXTERNALUUID = 'auto_group_externaluuid', } diff --git a/apps/server/src/modules/tool/common/service/common-tool-delete.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-delete.service.spec.ts new file mode 100644 index 00000000000..6e6023dbcd5 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/common-tool-delete.service.spec.ts @@ -0,0 +1,262 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { EventBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ContextExternalToolDeletedEvent } from '../../context-external-tool/domain'; +import { contextExternalToolFactory } from '../../context-external-tool/testing'; +import { externalToolFactory } from '../../external-tool/testing'; +import { SchoolExternalToolRef } from '../../school-external-tool/domain'; +import { schoolExternalToolFactory } from '../../school-external-tool/testing'; +import { CommonToolDeleteService } from './common-tool-delete.service'; + +describe(CommonToolDeleteService.name, () => { + let module: TestingModule; + let service: CommonToolDeleteService; + + let externalToolRepo: DeepMocked; + let schoolExternalToolRepo: DeepMocked; + let contextExternalToolRepo: DeepMocked; + let eventBus: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonToolDeleteService, + { + provide: ExternalToolRepo, + useValue: createMock(), + }, + { + provide: SchoolExternalToolRepo, + useValue: createMock(), + }, + { + provide: ContextExternalToolRepo, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(CommonToolDeleteService); + externalToolRepo = module.get(ExternalToolRepo); + schoolExternalToolRepo = module.get(SchoolExternalToolRepo); + contextExternalToolRepo = module.get(ContextExternalToolRepo); + eventBus = module.get(EventBus); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('deleteExternalTool', () => { + describe('when deleting an external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const displayName = 'test'; + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: new SchoolExternalToolRef({ + schoolToolId: schoolExternalTool.id, + }), + displayName, + }); + + externalToolRepo.findById.mockResolvedValueOnce(externalTool); + schoolExternalToolRepo.findByExternalToolId.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolRepo.find.mockResolvedValueOnce([contextExternalTool]); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + displayName, + }; + }; + + it('should delete the external tool', async () => { + const { externalTool } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(externalToolRepo.deleteById).toHaveBeenCalledWith(externalTool.id); + }); + + it('should delete the school external tools', async () => { + const { externalTool, schoolExternalTool } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(schoolExternalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalTool.id); + }); + + it('should delete the context external tools', async () => { + const { externalTool, contextExternalTool } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(contextExternalToolRepo.delete).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should use the correct school external tools', async () => { + const { externalTool } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(schoolExternalToolRepo.findByExternalToolId).toHaveBeenCalledWith(externalTool.id); + }); + + it('should use the correct context external tools', async () => { + const { externalTool, schoolExternalTool } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(contextExternalToolRepo.find).toHaveBeenCalledWith({ + schoolToolRef: { schoolToolId: schoolExternalTool.id }, + }); + }); + + it('should publish a delete event for the context external tools', async () => { + const { externalTool, contextExternalTool, displayName } = setup(); + + await service.deleteExternalTool(externalTool); + + expect(eventBus.publish).toHaveBeenCalledWith( + new ContextExternalToolDeletedEvent({ + id: contextExternalTool.id, + title: displayName, + }) + ); + }); + }); + }); + + describe('deleteSchoolExternalTool', () => { + describe('when deleting a school external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: new SchoolExternalToolRef({ + schoolToolId: schoolExternalTool.id, + }), + displayName: undefined, + }); + + externalToolRepo.findById.mockResolvedValueOnce(externalTool); + contextExternalToolRepo.find.mockResolvedValueOnce([contextExternalTool]); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should delete the school external tool', async () => { + const { schoolExternalTool } = setup(); + + await service.deleteSchoolExternalTool(schoolExternalTool); + + expect(schoolExternalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalTool.id); + }); + + it('should delete the context external tools', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + await service.deleteSchoolExternalTool(schoolExternalTool); + + expect(contextExternalToolRepo.delete).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should use the correct context external tools', async () => { + const { schoolExternalTool } = setup(); + + await service.deleteSchoolExternalTool(schoolExternalTool); + + expect(contextExternalToolRepo.find).toHaveBeenCalledWith({ + schoolToolRef: { schoolToolId: schoolExternalTool.id }, + }); + }); + + it('should publish a delete event for the context external tools', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.deleteSchoolExternalTool(schoolExternalTool); + + expect(eventBus.publish).toHaveBeenCalledWith( + new ContextExternalToolDeletedEvent({ + id: contextExternalTool.id, + title: externalTool.name, + }) + ); + }); + }); + }); + + describe('deleteContextExternalTool', () => { + describe('when deleting a context external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: new SchoolExternalToolRef({ + schoolToolId: schoolExternalTool.id, + }), + displayName: undefined, + }); + + schoolExternalToolRepo.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolRepo.findById.mockResolvedValueOnce(externalTool); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should delete the context external tool', async () => { + const { contextExternalTool } = setup(); + + await service.deleteContextExternalTool(contextExternalTool); + + expect(contextExternalToolRepo.delete).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should use the correct school external tools', async () => { + const { contextExternalTool } = setup(); + + await service.deleteContextExternalTool(contextExternalTool); + + expect(schoolExternalToolRepo.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); + }); + + it('should publish a delete event for the context external tools', async () => { + const { externalTool, contextExternalTool } = setup(); + + await service.deleteContextExternalTool(contextExternalTool); + + expect(eventBus.publish).toHaveBeenCalledWith( + new ContextExternalToolDeletedEvent({ + id: contextExternalTool.id, + title: externalTool.name, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/common-tool-delete.service.ts b/apps/server/src/modules/tool/common/service/common-tool-delete.service.ts new file mode 100644 index 00000000000..b3ebf7326fc --- /dev/null +++ b/apps/server/src/modules/tool/common/service/common-tool-delete.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; +import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ContextExternalTool, ContextExternalToolDeletedEvent } from '../../context-external-tool/domain'; +import type { ExternalTool } from '../../external-tool/domain'; +import type { SchoolExternalTool } from '../../school-external-tool/domain'; + +@Injectable() +export class CommonToolDeleteService { + constructor( + private readonly externalToolRepo: ExternalToolRepo, + private readonly schoolExternalToolRepo: SchoolExternalToolRepo, + private readonly contextExternalToolRepo: ContextExternalToolRepo, + private readonly eventBus: EventBus + ) {} + + public async deleteExternalTool(externalTool: ExternalTool): Promise { + await this.externalToolRepo.deleteById(externalTool.id); + + const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolRepo.findByExternalToolId( + externalTool.id + ); + + const promises: Promise[] = schoolExternalTools.map(async (schoolExternalTool) => { + await this.deleteSchoolExternalToolInternal(externalTool, schoolExternalTool); + }); + + await Promise.all(promises); + } + + public async deleteSchoolExternalTool(schoolExternalTool: SchoolExternalTool): Promise { + const externalTool: ExternalTool = await this.externalToolRepo.findById(schoolExternalTool.toolId); + + await this.deleteSchoolExternalToolInternal(externalTool, schoolExternalTool); + } + + public async deleteContextExternalTool(contextExternalTool: ContextExternalTool): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + + const externalTool: ExternalTool = await this.externalToolRepo.findById(schoolExternalTool.toolId); + + await this.deleteContextExternalToolInternal(externalTool, contextExternalTool); + } + + private async deleteSchoolExternalToolInternal( + externalTool: ExternalTool, + schoolExternalTool: SchoolExternalTool + ): Promise { + await this.schoolExternalToolRepo.deleteById(schoolExternalTool.id); + + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ + schoolToolRef: { schoolToolId: schoolExternalTool.id }, + }); + + const promises: Promise[] = contextExternalTools.map(async (contextExternalTool) => { + await this.deleteContextExternalToolInternal(externalTool, contextExternalTool); + }); + + await Promise.all(promises); + } + + private async deleteContextExternalToolInternal( + externalTool: ExternalTool, + contextExternalTool: ContextExternalTool + ): Promise { + await this.contextExternalToolRepo.delete(contextExternalTool); + + this.eventBus.publish( + new ContextExternalToolDeletedEvent({ + id: contextExternalTool.id, + title: contextExternalTool.displayName ?? externalTool.name, + }) + ); + } +} diff --git a/apps/server/src/modules/tool/common/service/index.ts b/apps/server/src/modules/tool/common/service/index.ts index b7f626f1e42..9a6567dbbcf 100644 --- a/apps/server/src/modules/tool/common/service/index.ts +++ b/apps/server/src/modules/tool/common/service/index.ts @@ -1,2 +1,3 @@ export * from './common-tool.service'; export { CommonToolValidationService, ToolParameterTypeValidationUtil } from './validation'; +export { CommonToolDeleteService } from './common-tool-delete.service'; diff --git a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts index 79cba0dd459..6cea79213fe 100644 --- a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts @@ -106,5 +106,16 @@ describe(ToolParameterTypeValidationUtil.name, () => { expect(result).toEqual(false); }); }); + + describe('when the type is AUTO_GROUPUUID', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_GROUP_EXTERNALUUID, + 'any value' + ); + + expect(result).toEqual(false); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts index 7c2b09cac5b..ad046f0ba3c 100644 --- a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts @@ -11,6 +11,7 @@ export class ToolParameterTypeValidationUtil { [CustomParameterType.AUTO_SCHOOLID]: () => false, [CustomParameterType.AUTO_SCHOOLNUMBER]: () => false, [CustomParameterType.AUTO_MEDIUMID]: () => false, + [CustomParameterType.AUTO_GROUP_EXTERNALUUID]: () => false, }; public static isValueValidForType(type: CustomParameterType, val: string): boolean { diff --git a/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts new file mode 100644 index 00000000000..5117a9bf8d2 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts @@ -0,0 +1,31 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ContextExternalTool } from '../domain'; +import { ContextExternalToolRequestMapper, ContextExternalToolResponseMapper } from '../mapper'; +import { AdminApiContextExternalToolUc } from '../uc'; +import { ContextExternalToolDto } from '../uc/dto/context-external-tool.types'; +import { ContextExternalToolPostParams, ContextExternalToolResponse } from './dto'; + +@ApiTags('AdminApi: Context External Tool') +@UseGuards(ApiKeyGuard) +@Controller('admin/tools/context-external-tools') +export class AdminApiContextExternalToolController { + constructor(private readonly adminApiContextExternalToolUc: AdminApiContextExternalToolUc) {} + + @Post() + @ApiOperation({ summary: 'Creates a ContextExternalTool' }) + async createContextExternalTool(@Body() body: ContextExternalToolPostParams): Promise { + const contextExternalToolProps: ContextExternalToolDto = + ContextExternalToolRequestMapper.mapContextExternalToolRequest(body); + + const contextExternalTool: ContextExternalTool = await this.adminApiContextExternalToolUc.createContextExternalTool( + contextExternalToolProps + ); + + const response: ContextExternalToolResponse = + ContextExternalToolResponseMapper.mapContextExternalToolResponse(contextExternalTool); + + return response; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts new file mode 100644 index 00000000000..9ad18358203 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts @@ -0,0 +1,142 @@ +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { serverConfig } from '@modules/server'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course, SchoolEntity } from '@shared/domain/entity'; +import { courseFactory, schoolEntityFactory, TestApiClient } from '@shared/testing'; +import { ToolContextType } from '../../../common/enum'; +import { ExternalToolResponse } from '../../../external-tool/controller/dto'; +import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; +import { customParameterEntityFactory, externalToolEntityFactory } from '../../../external-tool/testing'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; +import { ContextExternalToolEntity } from '../../entity'; +import { ContextExternalToolPostParams, ContextExternalToolResponse } from '../dto'; + +describe('AdminApiContextExternalTool (API)', () => { + let app: INestApplication; + let em: EntityManager; + let orm: MikroORM; + let testApiClient: TestApiClient; + + const apiKey = 'validApiKey'; + + const basePath = 'admin/tools/context-external-tools'; + + beforeAll(async () => { + serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; + + const module: TestingModule = await Test.createTestingModule({ + imports: [AdminApiServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath, apiKey, true); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await orm.getSchemaGenerator().clearDatabase(); + }); + + describe('[POST] admin/tools/context-external-tools', () => { + describe('when authenticating without an api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with an invalid api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath, 'invalidApiKey', true); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with a valid api token', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const course: Course = courseFactory.buildWithId({ school }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + displayName: course.name, + contextType: ToolContextType.COURSE, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: 'true' }, + ], + }; + + await em.persistAndFlush([school, externalToolEntity]); + em.clear(); + + return { + postParams, + }; + }; + + it('should create a context external tool', async () => { + const { postParams } = await setup(); + + const response = await testApiClient.post().send(postParams); + + const body: ExternalToolResponse = response.body as ExternalToolResponse; + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(body).toEqual({ + id: expect.any(String), + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: 'true' }, + ], + }); + + const contextExternalTool: ContextExternalToolEntity | null = await em.findOne(ContextExternalToolEntity, { + id: body.id, + }); + expect(contextExternalTool).toBeDefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/index.ts index 8c007639888..6927a20482c 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/index.ts @@ -1 +1,2 @@ export * from './tool-context.controller'; +export { AdminApiContextExternalToolController } from './admin-api-context-external-tool.controller'; 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 20d8b96f795..5b3bab2ed3d 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,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiCreatedResponse, @@ -26,7 +26,7 @@ import { } from './dto'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools/context-external-tools') export class ToolContextController { constructor(private readonly contextExternalToolUc: ContextExternalToolUc, private readonly logger: LegacyLogger) {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts index 1370e9a0a93..2f058b14899 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param } from '@nestjs/common'; import { ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { ToolReference } from '../domain'; @@ -12,7 +12,7 @@ import { } from './dto'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools/tool-references') export class ToolReferenceController { constructor(private readonly toolReferenceUc: ToolReferenceUc) {} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/event/context-external-tool-deleted.event.ts b/apps/server/src/modules/tool/context-external-tool/domain/event/context-external-tool-deleted.event.ts new file mode 100644 index 00000000000..a449fc3f54f --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/event/context-external-tool-deleted.event.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain/types'; + +export class ContextExternalToolDeletedEvent { + id: EntityId; + + title: string; + + constructor(props: ContextExternalToolDeletedEvent) { + this.id = props.id; + this.title = props.title; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/event/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/event/index.ts new file mode 100644 index 00000000000..825ef2f3479 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/event/index.ts @@ -0,0 +1 @@ +export * from './context-external-tool-deleted.event'; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index 17704725505..bb51be61682 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -1,4 +1,5 @@ export * from './context-external-tool.do'; export * from './context-ref'; export * from './tool-reference'; +export * from './event'; export { RestrictedContextMismatchLoggableException } from './error'; 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 1aeac5675ff..deaf6330fd5 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 @@ -8,11 +8,10 @@ import { ContextExternalToolRepo } from '@shared/repo'; import { legacySchoolDoFactory } from '@shared/testing'; import { CustomParameter } from '../../common/domain'; import { ToolContextType } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; +import { CommonToolDeleteService, CommonToolService } from '../../common/service'; import { ExternalToolService } from '../../external-tool'; import { ExternalTool } from '../../external-tool/domain'; -import { externalToolFactory } from '../../external-tool/testing'; -import { customParameterFactory } from '../../external-tool/testing/external-tool.factory'; +import { customParameterFactory, externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; @@ -28,10 +27,11 @@ import { ContextExternalToolService } from './context-external-tool.service'; describe(ContextExternalToolService.name, () => { let module: TestingModule; let service: ContextExternalToolService; + let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let commonToolService: DeepMocked; - + let commonToolDeleteService: DeepMocked; let contextExternalToolRepo: DeepMocked; beforeAll(async () => { @@ -58,6 +58,10 @@ describe(ContextExternalToolService.name, () => { provide: CommonToolService, useValue: createMock(), }, + { + provide: CommonToolDeleteService, + useValue: createMock(), + }, ], }).compile(); @@ -66,6 +70,7 @@ describe(ContextExternalToolService.name, () => { externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); commonToolService = module.get(CommonToolService); + commonToolDeleteService = module.get(CommonToolDeleteService); }); afterAll(async () => { @@ -98,43 +103,22 @@ describe(ContextExternalToolService.name, () => { }); }); - describe('deleteBySchoolExternalToolId', () => { - describe('when schoolExternalToolId is given', () => { + describe('deleteContextExternalTool', () => { + describe('when deleting a context external tool', () => { const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const schoolExternalToolId = schoolExternalTool.id; - const contextExternalTool1: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef(schoolExternalToolId) - .buildWithId(); - const contextExternalTool2: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef(schoolExternalToolId) - .buildWithId(); - contextExternalToolRepo.find.mockResolvedValueOnce([contextExternalTool1, contextExternalTool2]); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); return { - schoolExternalTool, - schoolExternalToolId, - contextExternalTool1, - contextExternalTool2, + contextExternalTool, }; }; - it('should call find()', async () => { - const { schoolExternalToolId } = setup(); - - await service.deleteBySchoolExternalToolId(schoolExternalToolId); - - expect(contextExternalToolRepo.find).toHaveBeenCalledWith({ - schoolToolRef: { schoolToolId: schoolExternalToolId }, - }); - }); - - it('should call deleteBySchoolExternalToolIds()', async () => { - const { schoolExternalToolId, contextExternalTool1, contextExternalTool2 } = setup(); + it('should delete the context external tool', async () => { + const { contextExternalTool } = setup(); - await service.deleteBySchoolExternalToolId(schoolExternalToolId); + await service.deleteContextExternalTool(contextExternalTool); - expect(contextExternalToolRepo.delete).toHaveBeenCalledWith([contextExternalTool1, contextExternalTool2]); + expect(commonToolDeleteService.deleteContextExternalTool).toHaveBeenCalledWith(contextExternalTool); }); }); }); @@ -244,26 +228,6 @@ describe(ContextExternalToolService.name, () => { }); }); - describe('deleteContextExternalTool', () => { - describe('when contextExternalToolId is given', () => { - const setup = () => { - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - return { - contextExternalTool, - }; - }; - - it('should call delete on repo', async () => { - const { contextExternalTool } = setup(); - - await service.deleteContextExternalTool(contextExternalTool); - - expect(contextExternalToolRepo.delete).toHaveBeenCalledWith(contextExternalTool); - }); - }); - }); - describe('getContextExternalToolsForContext', () => { describe('when contextType and contextId are given', () => { it('should call the repository', async () => { 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 7ba0878bfd3..43f7d5f82ae 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 @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { ContextExternalToolRepo } from '@shared/repo'; import { CustomParameter, CustomParameterEntry } from '../../common/domain'; -import { CommonToolService } from '../../common/service'; +import { CommonToolDeleteService, CommonToolService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; @@ -22,7 +22,8 @@ export class ContextExternalToolService { private readonly contextExternalToolRepo: ContextExternalToolRepo, private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, - private readonly commonToolService: CommonToolService + private readonly commonToolService: CommonToolService, + private readonly commonToolDeleteService: CommonToolDeleteService ) {} public async findContextExternalTools(query: ContextExternalToolQuery): Promise { @@ -49,18 +50,8 @@ export class ContextExternalToolService { return savedContextExternalTool; } - public async deleteBySchoolExternalToolId(schoolExternalToolId: EntityId) { - const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ - schoolToolRef: { - schoolToolId: schoolExternalToolId, - }, - }); - - await this.contextExternalToolRepo.delete(contextExternalTools); - } - public async deleteContextExternalTool(contextExternalTool: ContextExternalTool): Promise { - await this.contextExternalToolRepo.delete(contextExternalTool); + await this.commonToolDeleteService.deleteContextExternalTool(contextExternalTool); } public async findAllByContext(contextRef: ContextRef): Promise { diff --git a/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool.factory.ts index 88827f685c9..9d6140829cc 100644 --- a/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool.factory.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool.factory.ts @@ -3,7 +3,8 @@ import { DoBaseFactory } from '@shared/testing/factory/domainobject/do-base.fact import { DeepPartial } from 'fishery'; import { CustomParameterEntry } from '../../common/domain'; import { ToolContextType } from '../../common/enum'; -import { ContextExternalTool, ContextExternalToolProps } from '../domain'; +import { SchoolExternalToolRef } from '../../school-external-tool/domain'; +import { ContextExternalTool, ContextExternalToolProps, ContextRef } from '../domain'; class ContextExternalToolFactory extends DoBaseFactory { withSchoolExternalToolRef(schoolToolId: string, schoolId?: string | undefined): this { @@ -24,8 +25,8 @@ class ContextExternalToolFactory extends DoBaseFactory { return { id: new ObjectId().toHexString(), - schoolToolRef: { schoolToolId: `schoolToolId-${sequence}`, schoolId: 'schoolId' }, - contextRef: { id: new ObjectId().toHexString(), type: ToolContextType.COURSE }, + schoolToolRef: new SchoolExternalToolRef({ schoolToolId: `schoolToolId-${sequence}`, schoolId: 'schoolId' }), + contextRef: new ContextRef({ id: new ObjectId().toHexString(), type: ToolContextType.COURSE }), displayName: 'My Course Tool 1', parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], }; diff --git a/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.spec.ts new file mode 100644 index 00000000000..7fcd5fbebef --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolService } from '../service'; +import { contextExternalToolFactory } from '../testing'; +import { AdminApiContextExternalToolUc } from './admin-api-context-external-tool.uc'; + +describe(AdminApiContextExternalToolUc.name, () => { + let module: TestingModule; + let uc: AdminApiContextExternalToolUc; + + let contextExternalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + AdminApiContextExternalToolUc, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(AdminApiContextExternalToolUc); + contextExternalToolService = module.get(ContextExternalToolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('createExternalTool', () => { + describe('when creating a tool', () => { + const setup = () => { + const contextExternalTool = contextExternalToolFactory.build(); + + contextExternalToolService.saveContextExternalTool.mockResolvedValueOnce(contextExternalTool); + + return { + contextExternalTool, + }; + }; + + it('should save the tool', async () => { + const { contextExternalTool } = setup(); + + await uc.createContextExternalTool(contextExternalTool.getProps()); + + expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should return the tool', async () => { + const { contextExternalTool } = setup(); + + const result = await uc.createContextExternalTool(contextExternalTool.getProps()); + + expect(result).toEqual(contextExternalTool); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.ts new file mode 100644 index 00000000000..8f0636a0808 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/uc/admin-api-context-external-tool.uc.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ContextExternalTool } from '../domain'; +import { ContextExternalToolService } from '../service'; +import { ContextExternalToolDto } from './dto/context-external-tool.types'; + +@Injectable() +export class AdminApiContextExternalToolUc { + constructor(private readonly contextExternalToolService: ContextExternalToolService) {} + + async createContextExternalTool(contextExternalToolProps: ContextExternalToolDto): Promise { + const contextExternalTool: ContextExternalTool = new ContextExternalTool(contextExternalToolProps); + + const createdTool: ContextExternalTool = await this.contextExternalToolService.saveContextExternalTool( + contextExternalTool + ); + + return createdTool; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/uc/index.ts b/apps/server/src/modules/tool/context-external-tool/uc/index.ts index 12f2a82a9f1..15da5a9b97e 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/index.ts @@ -1,2 +1,3 @@ +export { AdminApiContextExternalToolUc } from './admin-api-context-external-tool.uc'; export * from './context-external-tool.uc'; export * from './tool-reference.uc'; diff --git a/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts new file mode 100644 index 00000000000..5d92c67f142 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts @@ -0,0 +1,30 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ExternalTool } from '../domain'; + +import { ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper'; +import { AdminApiExternalToolUc, ExternalToolCreate } from '../uc'; +import { ExternalToolCreateParams, ExternalToolResponse } from './dto'; + +@ApiTags('AdminApi: External Tools') +@UseGuards(ApiKeyGuard) +@Controller('admin/tools/external-tools') +export class AdminApiExternalToolController { + constructor( + private readonly adminApiExternalToolUc: AdminApiExternalToolUc, + private readonly externalToolDOMapper: ExternalToolRequestMapper + ) {} + + @Post() + @ApiOperation({ summary: 'Creates an ExternalTool' }) + async createExternalTool(@Body() externalToolParams: ExternalToolCreateParams): Promise { + const externalTool: ExternalToolCreate = this.externalToolDOMapper.mapCreateRequest(externalToolParams); + + const created: ExternalTool = await this.adminApiExternalToolUc.createExternalTool(externalTool); + + const mapped: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(created); + + return mapped; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts new file mode 100644 index 00000000000..9d9a9b7af34 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts @@ -0,0 +1,149 @@ +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { serverConfig } from '@modules/server'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestApiClient } from '@shared/testing'; +import { Response } from 'supertest'; +import { + CustomParameterLocationParams, + CustomParameterScopeTypeParams, + CustomParameterTypeParams, + ToolConfigType, +} from '../../../common/enum'; +import { ExternalToolEntity } from '../../entity'; +import { ExternalToolCreateParams, ExternalToolResponse } from '../dto'; + +describe('AdminApiExternalTool (API)', () => { + let app: INestApplication; + let em: EntityManager; + let orm: MikroORM; + let testApiClient: TestApiClient; + + const apiKey = 'validApiKey'; + + const basePath = 'admin/tools/external-tools'; + + beforeAll(async () => { + serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; + + const module: TestingModule = await Test.createTestingModule({ + imports: [AdminApiServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath, apiKey, true); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await orm.getSchemaGenerator().clearDatabase(); + }); + + describe('[POST] admin/tools/external-tools', () => { + describe('when authenticating without an api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with an invalid api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath, 'invalidApiKey', true); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with a valid api token', () => { + describe('when valid data is given', () => { + const setup = () => { + const postParams: ExternalToolCreateParams = { + name: 'Tool 1', + parameters: [ + { + name: 'key', + description: 'This is a parameter.', + displayName: 'User Friendly Name', + defaultValue: 'abc', + isOptional: false, + isProtected: false, + type: CustomParameterTypeParams.STRING, + regex: 'abc', + regexComment: 'Regex accepts "abc" as value.', + location: CustomParameterLocationParams.PATH, + scope: CustomParameterScopeTypeParams.GLOBAL, + }, + ], + config: { + type: ToolConfigType.BASIC, + baseUrl: 'https://link.to-my-tool.com/:key', + }, + isHidden: false, + isDeactivated: false, + logoUrl: 'https://link.to-my-logo.com', + url: 'https://link.to-my-tool.com', + openNewTab: true, + thumbnailUrl: 'https://link.to-my-thumbnail.com', + }; + + return { + postParams, + }; + }; + + it('should create a tool', async () => { + const { postParams } = setup(); + + const response: Response = await testApiClient.post().send(postParams); + + const body: ExternalToolResponse = response.body as ExternalToolResponse; + + expect(response.status).toEqual(HttpStatus.CREATED); + expect(body).toEqual({ + id: body.id, + name: 'Tool 1', + parameters: [ + { + name: 'key', + description: 'This is a parameter.', + displayName: 'User Friendly Name', + defaultValue: 'abc', + isOptional: false, + isProtected: false, + type: CustomParameterTypeParams.STRING, + regex: 'abc', + regexComment: 'Regex accepts "abc" as value.', + location: CustomParameterLocationParams.PATH, + scope: CustomParameterScopeTypeParams.GLOBAL, + }, + ], + config: { + type: ToolConfigType.BASIC, + baseUrl: 'https://link.to-my-tool.com/:key', + }, + isHidden: false, + isDeactivated: false, + url: 'https://link.to-my-tool.com', + openNewTab: true, + }); + + const externalTool: ExternalToolEntity | null = await em.findOne(ExternalToolEntity, { id: body.id }); + expect(externalTool).toBeDefined(); + }); + }); + }); + }); +}); 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 6a646b2d5b6..c0242b68070 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 @@ -714,21 +714,21 @@ describe('ToolController (API)', () => { describe('when permission is missing', () => { const setup = async () => { - const toolId: string = new ObjectId().toHexString(); + const externalTool = externalToolEntityFactory.build(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin(); - await em.persistAndFlush([adminAccount, adminUser]); + await em.persistAndFlush([adminAccount, adminUser, externalTool]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - return { loggedInClient, toolId }; + return { loggedInClient, externalTool }; }; it('should return unauthorized', async () => { - const { loggedInClient, toolId } = await setup(); + const { loggedInClient, externalTool } = await setup(); - const response: Response = await loggedInClient.delete(`${toolId}`); + const response: Response = await loggedInClient.delete(externalTool.id); expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/index.ts b/apps/server/src/modules/tool/external-tool/controller/index.ts index c7a9ec8e81d..f3f1c08f086 100644 --- a/apps/server/src/modules/tool/external-tool/controller/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/index.ts @@ -1,2 +1,3 @@ export * from './tool.controller'; export * from './tool-configuration.controller'; +export { AdminApiExternalToolController } from './admin-api-external-tool.controller'; 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 48a3ff0031d..7b90edab1df 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,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param } from '@nestjs/common'; import { ApiForbiddenResponse, @@ -8,6 +8,7 @@ import { ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; +import { ToolContextType } from '../../common/enum'; import { ExternalTool } from '../domain'; import { ToolConfigurationMapper } from '../mapper/tool-configuration.mapper'; import { ContextExternalToolTemplateInfo, ExternalToolConfigurationUc } from '../uc'; @@ -22,10 +23,9 @@ import { SchoolIdParams, ToolContextTypesListResponse, } from './dto'; -import { ToolContextType } from '../../common/enum'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools') export class ToolConfigurationController { constructor(private readonly externalToolConfigurationUc: ExternalToolConfigurationUc) {} 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 c23258341bc..3bbe1bdabee 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 { Authenticate, CurrentUser, ICurrentUser, JWT } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JWT, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, @@ -52,7 +52,7 @@ import { } from './dto'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools/external-tools') export class ToolController { constructor( 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 695e4cad02a..cdb07b5f74c 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 @@ -2,7 +2,6 @@ import { EncryptionModule } from '@infra/encryption'; import { OauthProviderServiceModule } from '@modules/oauth-provider'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { ExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { InstanceModule } from '../../instance'; import { CommonToolModule } from '../common'; @@ -29,7 +28,6 @@ import { ExternalToolValidationService, ExternalToolConfigurationService, ExternalToolLogoService, - ExternalToolRepo, ExternalToolMetadataMapper, ToolContextMapper, DatasheetPdfService, 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 96e0ef3dff7..5805ee736e6 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 @@ -57,6 +57,7 @@ const typeMapping: Record = { [CustomParameterTypeParams.AUTO_SCHOOLID]: CustomParameterType.AUTO_SCHOOLID, [CustomParameterTypeParams.AUTO_SCHOOLNUMBER]: CustomParameterType.AUTO_SCHOOLNUMBER, [CustomParameterTypeParams.AUTO_MEDIUMID]: CustomParameterType.AUTO_MEDIUMID, + [CustomParameterTypeParams.AUTO_GROUP_EXTERNALUUID]: CustomParameterType.AUTO_GROUP_EXTERNALUUID, }; @Injectable() 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 7a91f127cd8..13a68d0e834 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 @@ -42,6 +42,7 @@ const typeMapping: Record = { [CustomParameterType.AUTO_SCHOOLID]: CustomParameterTypeParams.AUTO_SCHOOLID, [CustomParameterType.AUTO_SCHOOLNUMBER]: CustomParameterTypeParams.AUTO_SCHOOLNUMBER, [CustomParameterType.AUTO_MEDIUMID]: CustomParameterTypeParams.AUTO_MEDIUMID, + [CustomParameterType.AUTO_GROUP_EXTERNALUUID]: CustomParameterTypeParams.AUTO_GROUP_EXTERNALUUID, }; @Injectable() 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 956827963ab..ea54061dd37 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 @@ -5,13 +5,12 @@ import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions, SortOrder } from '@shared/domain/interface'; -import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ExternalToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { OauthProviderService } from '../../../oauth-provider/domain/service/oauth-provider.service'; import { providerOauthClientFactory } from '../../../oauth-provider/testing'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { schoolExternalToolFactory } from '../../school-external-tool/testing'; +import { CommonToolDeleteService } from '../../common/service'; import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; import { externalToolFactory, lti11ToolConfigFactory, oauth2ToolConfigFactory } from '../testing'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; @@ -22,9 +21,8 @@ describe(ExternalToolService.name, () => { let service: ExternalToolService; let externalToolRepo: DeepMocked; - let schoolToolRepo: DeepMocked; - let courseToolRepo: DeepMocked; let oauthProviderService: DeepMocked; + let commonToolDeleteService: DeepMocked; let mapper: DeepMocked; let encryptionService: DeepMocked; @@ -48,27 +46,22 @@ describe(ExternalToolService.name, () => { provide: DefaultEncryptionService, useValue: createMock(), }, - { - provide: SchoolExternalToolRepo, - useValue: createMock(), - }, - { - provide: ContextExternalToolRepo, - useValue: createMock(), - }, { provide: LegacyLogger, useValue: createMock(), }, + { + provide: CommonToolDeleteService, + useValue: createMock(), + }, ], }).compile(); service = module.get(ExternalToolService); externalToolRepo = module.get(ExternalToolRepo); - schoolToolRepo = module.get(SchoolExternalToolRepo); - courseToolRepo = module.get(ContextExternalToolRepo); oauthProviderService = module.get(OauthProviderService); mapper = module.get(ExternalToolServiceMapper); + commonToolDeleteService = module.get(CommonToolDeleteService); encryptionService = module.get(DefaultEncryptionService); }); @@ -375,40 +368,20 @@ describe(ExternalToolService.name, () => { describe('deleteExternalTool', () => { const setup = () => { - createTools(); - - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - - schoolToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalTool]); + const externalTool = externalToolFactory.build(); return { - schoolExternalTool, + externalTool, }; }; - describe('when tool id is set', () => { - it('should delete all related CourseExternalTools', async () => { - const { schoolExternalTool } = setup(); - - await service.deleteExternalTool(schoolExternalTool.toolId); - - expect(courseToolRepo.deleteBySchoolExternalToolIds).toHaveBeenCalledWith([schoolExternalTool.id]); - }); - - it('should delete all related SchoolExternalTools', async () => { - const { schoolExternalTool } = setup(); - - await service.deleteExternalTool(schoolExternalTool.toolId); - - expect(schoolToolRepo.deleteByExternalToolId).toHaveBeenCalledWith(schoolExternalTool.toolId); - }); - - it('should delete the ExternalTool', async () => { - const { schoolExternalTool } = setup(); + describe('when deleting an external tool', () => { + it('should delete the external tool', async () => { + const { externalTool } = setup(); - await service.deleteExternalTool(schoolExternalTool.toolId); + await service.deleteExternalTool(externalTool); - expect(externalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalTool.toolId); + expect(commonToolDeleteService.deleteExternalTool).toHaveBeenCalledWith(externalTool); }); }); }); 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 ae477c00692..62d1bb39b58 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 @@ -5,11 +5,11 @@ import { Inject, Injectable, UnprocessableEntityException } from '@nestjs/common import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ExternalToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { TokenEndpointAuthMethod } from '../../common/enum'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { CommonToolDeleteService } from '../../common/service'; import { ExternalTool, Oauth2ToolConfig } from '../domain'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; @@ -19,10 +19,9 @@ export class ExternalToolService { private readonly externalToolRepo: ExternalToolRepo, private readonly oauthProviderService: OauthProviderService, private readonly mapper: ExternalToolServiceMapper, - private readonly schoolExternalToolRepo: SchoolExternalToolRepo, - private readonly contextExternalToolRepo: ContextExternalToolRepo, @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService, - private readonly legacyLogger: LegacyLogger + private readonly legacyLogger: LegacyLogger, + private readonly commonToolDeleteService: CommonToolDeleteService ) {} public async createExternalTool(externalTool: ExternalTool): Promise { @@ -63,8 +62,8 @@ export class ExternalToolService { try { await this.addExternalOauth2DataToConfig(tool.config); } catch (e) { - this.legacyLogger.debug( - `Could not resolve oauth2Config of tool with clientId ${tool.config.clientId}. It will be filtered out.` + this.legacyLogger.warn( + `Could not resolve oauth2Config of tool with clientId ${tool.config.clientId} and name ${tool.name}. It will be filtered out.` ); return undefined; } @@ -108,17 +107,8 @@ export class ExternalToolService { return externalTool; } - public async deleteExternalTool(toolId: EntityId): Promise { - const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolRepo.findByExternalToolId(toolId); - const schoolExternalToolIds: string[] = schoolExternalTools.map( - (schoolExternalTool: SchoolExternalTool): string => schoolExternalTool.id - ); - - await Promise.all([ - this.contextExternalToolRepo.deleteBySchoolExternalToolIds(schoolExternalToolIds), - this.schoolExternalToolRepo.deleteByExternalToolId(toolId), - this.externalToolRepo.deleteById(toolId), - ]); + public async deleteExternalTool(externalTool: ExternalTool): Promise { + await this.commonToolDeleteService.deleteExternalTool(externalTool); } private async updateOauth2ToolConfig(toUpdate: ExternalTool) { diff --git a/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.spec.ts new file mode 100644 index 00000000000..51cacc81215 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.spec.ts @@ -0,0 +1,73 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExternalTool } from '../domain'; +import { ExternalToolService } from '../service'; +import { externalToolFactory } from '../testing'; +import { AdminApiExternalToolUc } from './admin-api-external-tool.uc'; + +describe(AdminApiExternalToolUc.name, () => { + let module: TestingModule; + let uc: AdminApiExternalToolUc; + + let externalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + AdminApiExternalToolUc, + { + provide: ExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(AdminApiExternalToolUc); + externalToolService = module.get(ExternalToolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('createExternalTool', () => { + describe('when creating a tool', () => { + const setup = () => { + const externalTool = externalToolFactory.build(); + + externalToolService.createExternalTool.mockResolvedValueOnce(externalTool); + + return { + externalTool, + }; + }; + + it('should save the tool', async () => { + const { externalTool } = setup(); + + await uc.createExternalTool(externalTool.getProps()); + + expect(externalToolService.createExternalTool).toHaveBeenCalledWith( + new ExternalTool({ + ...externalTool.getProps(), + id: expect.any(String), + logoUrl: undefined, + thumbnail: undefined, + }) + ); + }); + + it('should return the tool', async () => { + const { externalTool } = setup(); + + const result = await uc.createExternalTool(externalTool.getProps()); + + expect(result).toEqual(externalTool); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.ts b/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.ts new file mode 100644 index 00000000000..18c62d15be0 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/uc/admin-api-external-tool.uc.ts @@ -0,0 +1,23 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { ExternalTool } from '../domain'; +import { ExternalToolService } from '../service'; +import { ExternalToolCreate } from './dto'; + +@Injectable() +export class AdminApiExternalToolUc { + constructor(private readonly externalToolService: ExternalToolService) {} + + public async createExternalTool(externalToolCreate: ExternalToolCreate): Promise { + const { thumbnailUrl, logoUrl, ...externalToolCreateProps } = externalToolCreate; + + const pendingExternalTool: ExternalTool = new ExternalTool({ + ...externalToolCreateProps, + id: new ObjectId().toHexString(), + }); + + const savedExternalTool: ExternalTool = await this.externalToolService.createExternalTool(pendingExternalTool); + + return savedExternalTool; + } +} 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 5909047186f..b3af5f0e83e 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 @@ -1,7 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; import { School, SchoolService } from '@modules/school'; import { schoolFactory } from '@modules/school/testing'; @@ -12,7 +11,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject/page'; import { Role, User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; -import { roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { currentUserFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; import { CustomParameter } from '../../common/domain'; import { ExternalToolSearchQuery } from '../../common/interface'; import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; @@ -125,7 +124,7 @@ describe(ExternalToolUc.name, () => { const setupAuthorization = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); authorizationService.getUserWithPermissions.mockResolvedValue(user); @@ -262,7 +261,7 @@ describe(ExternalToolUc.name, () => { describe('when fetching logo', () => { const setup = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const externalTool: ExternalTool = externalToolFactory.buildWithId(); @@ -288,7 +287,7 @@ describe(ExternalToolUc.name, () => { describe('when thumbnail url is given', () => { const setup = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const externalTool: ExternalToolCreate = { ...externalToolFactory.buildWithId().getProps(), @@ -748,7 +747,7 @@ describe(ExternalToolUc.name, () => { describe('when fetching logo', () => { const setupLogo = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const externalTool: ExternalTool = externalToolFactory.buildWithId(); externalToolService.findById.mockResolvedValue(externalTool); @@ -775,7 +774,7 @@ describe(ExternalToolUc.name, () => { describe('when no thumbnail url is given and previous is existing', () => { const setupThumbnail = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const externalTool: ExternalToolUpdate = { ...externalToolFactory.buildWithId().getProps(), @@ -810,7 +809,7 @@ describe(ExternalToolUc.name, () => { describe('when thumbnail url is given', () => { const setupThumbnail = () => { const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const externalTool: ExternalToolUpdate = { ...externalToolFactory.buildWithId().getProps(), @@ -874,17 +873,20 @@ describe(ExternalToolUc.name, () => { describe('deleteExternalTool', () => { const setup = () => { const toolId = 'toolId'; - const currentUser: ICurrentUser = { userId: 'userId' } as ICurrentUser; + const currentUser = currentUserFactory.build(); const user: User = userFactory.buildWithId(); const jwt = 'jwt'; + const externalTool = externalToolFactory.build(); authorizationService.getUserWithPermissions.mockResolvedValue(user); + externalToolService.findById.mockResolvedValueOnce(externalTool); return { toolId, currentUser, user, jwt, + externalTool, }; }; @@ -898,11 +900,11 @@ describe(ExternalToolUc.name, () => { }); it('should call ExternalToolService', async () => { - const { toolId, currentUser } = setup(); + const { toolId, currentUser, externalTool } = setup(); await uc.deleteExternalTool(currentUser.userId, toolId, 'jwt'); - expect(externalToolService.deleteExternalTool).toHaveBeenCalledWith(toolId); + expect(externalToolService.deleteExternalTool).toHaveBeenCalledWith(externalTool); }); it('should call ExternalToolImageService', async () => { @@ -922,7 +924,7 @@ describe(ExternalToolUc.name, () => { const role: Role = roleFactory.buildWithId({ permissions: [Permission.TOOL_ADMIN] }); const user: User = userFactory.buildWithId({ roles: [role] }); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); const context = { action: Action.read, requiredPermissions: [Permission.TOOL_ADMIN] }; externalToolService.findById.mockResolvedValue(tool); @@ -989,7 +991,7 @@ describe(ExternalToolUc.name, () => { commonToolMetadataService.getMetadataForExternalTool.mockResolvedValue(externalToolMetadata); const user: User = userFactory.buildWithId(); - const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const currentUser = currentUserFactory.build(); authorizationService.getUserWithPermissions.mockResolvedValue(user); 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 dbdc435035d..bc79c03f090 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 @@ -177,11 +177,13 @@ export class ExternalToolUc { } public async deleteExternalTool(userId: EntityId, externalToolId: EntityId, jwt: string): Promise { + const externalTool: ExternalTool = await this.externalToolService.findById(externalToolId); + await this.ensurePermission(userId, Permission.TOOL_ADMIN); await this.externalToolImageService.deleteAllFiles(externalToolId, jwt); - await this.externalToolService.deleteExternalTool(externalToolId); + await this.externalToolService.deleteExternalTool(externalTool); } public async getMetadataForExternalTool(userId: EntityId, toolId: EntityId): Promise { diff --git a/apps/server/src/modules/tool/external-tool/uc/index.ts b/apps/server/src/modules/tool/external-tool/uc/index.ts index 0a61273b29b..82455ff66f1 100644 --- a/apps/server/src/modules/tool/external-tool/uc/index.ts +++ b/apps/server/src/modules/tool/external-tool/uc/index.ts @@ -1,3 +1,4 @@ export * from './dto'; export * from './external-tool.uc'; export * from './external-tool-configuration.uc'; +export { AdminApiExternalToolUc } from './admin-api-external-tool.uc'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/admin-api-school-external-tool.controller.ts b/apps/server/src/modules/tool/school-external-tool/controller/admin-api-school-external-tool.controller.ts new file mode 100644 index 00000000000..05e29a43d3d --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/controller/admin-api-school-external-tool.controller.ts @@ -0,0 +1,30 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SchoolExternalTool, SchoolExternalToolProps } from '../domain'; +import { SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper } from '../mapper'; +import { AdminApiSchoolExternalToolUc } from '../uc'; +import { SchoolExternalToolPostParams, SchoolExternalToolResponse } from './dto'; + +@ApiTags('AdminApi: School External Tool') +@UseGuards(ApiKeyGuard) +@Controller('admin/tools/school-external-tools') +export class AdminApiSchoolExternalToolController { + constructor(private readonly adminApiSchoolExternalToolUc: AdminApiSchoolExternalToolUc) {} + + @Post() + @ApiOperation({ summary: 'Creates a SchoolExternalTool' }) + async createSchoolExternalTool(@Body() body: SchoolExternalToolPostParams): Promise { + const schoolExternalToolProps: SchoolExternalToolProps = + SchoolExternalToolRequestMapper.mapSchoolExternalToolRequest(body); + + const schoolExternalTool: SchoolExternalTool = await this.adminApiSchoolExternalToolUc.createSchoolExternalTool( + schoolExternalToolProps + ); + + const response: SchoolExternalToolResponse = + SchoolExternalToolResponseMapper.mapToSchoolExternalToolResponse(schoolExternalTool); + + return response; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts new file mode 100644 index 00000000000..d3906501ec8 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts @@ -0,0 +1,137 @@ +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { serverConfig } from '@modules/server'; +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity } from '@shared/domain/entity'; +import { schoolEntityFactory, TestApiClient } from '@shared/testing'; +import { ExternalToolResponse } from '../../../external-tool/controller/dto'; +import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; +import { customParameterEntityFactory, externalToolEntityFactory } from '../../../external-tool/testing'; +import { SchoolExternalToolEntity } from '../../entity'; +import { schoolExternalToolConfigurationStatusFactory } from '../../testing'; +import { SchoolExternalToolPostParams, SchoolExternalToolResponse } from '../dto'; + +describe('AdminApiSchoolExternalTool (API)', () => { + let app: INestApplication; + let em: EntityManager; + let orm: MikroORM; + let testApiClient: TestApiClient; + + const apiKey = 'validApiKey'; + + const basePath = 'admin/tools/school-external-tools'; + + beforeAll(async () => { + serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; + + const module: TestingModule = await Test.createTestingModule({ + imports: [AdminApiServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath, apiKey, true); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await orm.getSchemaGenerator().clearDatabase(); + }); + + describe('[POST] admin/tools/school-external-tools', () => { + describe('when authenticating without an api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with an invalid api token', () => { + it('should return unauthorized', async () => { + const client = new TestApiClient(app, basePath, 'invalidApiKey', true); + + const response = await client.post(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when authenticating with a valid api token', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], + }); + + const postParams: SchoolExternalToolPostParams = { + toolId: externalToolEntity.id, + schoolId: school.id, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: 'false' }, + ], + isDeactivated: false, + }; + + await em.persistAndFlush([school, externalToolEntity]); + em.clear(); + + return { + postParams, + externalToolEntity, + }; + }; + + it('should create a school external tool', async () => { + const { postParams, externalToolEntity } = await setup(); + + const response = await testApiClient.post().send(postParams); + + const body: ExternalToolResponse = response.body as ExternalToolResponse; + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(body).toEqual({ + id: expect.any(String), + isDeactivated: postParams.isDeactivated, + name: externalToolEntity.name, + schoolId: postParams.schoolId, + toolId: postParams.toolId, + status: schoolExternalToolConfigurationStatusFactory.build({ + isOutdatedOnScopeSchool: false, + }), + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: 'false' }, + ], + }); + + const schoolExternalTool: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { + id: body.id, + }); + expect(schoolExternalTool).toBeDefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/index.ts b/apps/server/src/modules/tool/school-external-tool/controller/index.ts index 9017ffb825e..ac0defb0474 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/index.ts @@ -1 +1,2 @@ export * from './tool-school.controller'; +export { AdminApiSchoolExternalToolController } from './admin-api-school-external-tool.controller'; 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 abfff864d11..c942ebb02c2 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 @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -32,7 +32,7 @@ import { } from './dto'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools/school-external-tools') export class ToolSchoolController { constructor(private readonly schoolExternalToolUc: SchoolExternalToolUc, private readonly logger: LegacyLogger) {} 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 9f3e50b6b9a..09433388abd 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 @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; import { SchoolExternalToolRepo } from '@shared/repo'; -import { CommonToolValidationService } from '../../common/service'; +import { CommonToolDeleteService, CommonToolValidationService } from '../../common/service'; import { ExternalToolService } from '../../external-tool'; import { type ExternalTool } from '../../external-tool/domain'; import { externalToolFactory } from '../../external-tool/testing'; @@ -18,6 +18,7 @@ describe(SchoolExternalToolService.name, () => { let schoolExternalToolRepo: DeepMocked; let externalToolService: DeepMocked; let commonToolValidationService: DeepMocked; + let commonToolDeleteService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -35,6 +36,10 @@ describe(SchoolExternalToolService.name, () => { provide: CommonToolValidationService, useValue: createMock(), }, + { + provide: CommonToolDeleteService, + useValue: createMock(), + }, ], }).compile(); @@ -42,6 +47,7 @@ describe(SchoolExternalToolService.name, () => { schoolExternalToolRepo = module.get(SchoolExternalToolRepo); externalToolService = module.get(ExternalToolService); commonToolValidationService = module.get(CommonToolValidationService); + commonToolDeleteService = module.get(CommonToolDeleteService); }); describe('findSchoolExternalTools', () => { @@ -101,26 +107,22 @@ describe(SchoolExternalToolService.name, () => { }); }); - describe('deleteSchoolExternalToolById', () => { - describe('when schoolExternalToolId is given', () => { + describe('deleteSchoolExternalTool', () => { + describe('when schoolExternalTool is given', () => { const setup = () => { const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); return { - schoolExternalToolId: schoolExternalTool.id, + schoolExternalTool, }; }; - it('should call the schoolExternalToolRepo', () => { - const { schoolExternalToolId } = setup(); + it('should call the schoolExternalToolRepo', async () => { + const { schoolExternalTool } = setup(); - service.deleteSchoolExternalToolById(schoolExternalToolId); + await service.deleteSchoolExternalTool(schoolExternalTool); - expect(schoolExternalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalToolId); + expect(commonToolDeleteService.deleteSchoolExternalTool).toHaveBeenCalledWith(schoolExternalTool); }); }); }); 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 978f0aa4ee5..809cbd94f3c 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 @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { SchoolExternalToolRepo } from '@shared/repo'; -import { CommonToolValidationService } from '../../common/service'; +import { CommonToolDeleteService, CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool, SchoolExternalToolConfigurationStatus } from '../domain'; @@ -13,7 +13,8 @@ export class SchoolExternalToolService { constructor( private readonly schoolExternalToolRepo: SchoolExternalToolRepo, private readonly externalToolService: ExternalToolService, - private readonly commonToolValidationService: CommonToolValidationService + private readonly commonToolValidationService: CommonToolValidationService, + private readonly commonToolDeleteService: CommonToolDeleteService ) {} public async findById(schoolExternalToolId: EntityId): Promise { @@ -79,8 +80,8 @@ export class SchoolExternalToolService { return status; } - public deleteSchoolExternalToolById(schoolExternalToolId: EntityId): void { - this.schoolExternalToolRepo.deleteById(schoolExternalToolId); + public async deleteSchoolExternalTool(schoolExternalTool: SchoolExternalTool): Promise { + await this.commonToolDeleteService.deleteSchoolExternalTool(schoolExternalTool); } public async saveSchoolExternalTool(schoolExternalTool: SchoolExternalTool): Promise { diff --git a/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.spec.ts b/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.spec.ts new file mode 100644 index 00000000000..56f76e4baf4 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolExternalToolService } from '../service'; +import { schoolExternalToolFactory } from '../testing'; +import { AdminApiSchoolExternalToolUc } from './admin-api-school-external-tool.uc'; + +describe(AdminApiSchoolExternalToolUc.name, () => { + let module: TestingModule; + let uc: AdminApiSchoolExternalToolUc; + + let schoolExternalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + AdminApiSchoolExternalToolUc, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(AdminApiSchoolExternalToolUc); + schoolExternalToolService = module.get(SchoolExternalToolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('createExternalTool', () => { + describe('when creating a tool', () => { + const setup = () => { + const schoolExternalTool = schoolExternalToolFactory.build(); + + schoolExternalToolService.saveSchoolExternalTool.mockResolvedValueOnce(schoolExternalTool); + + return { + schoolExternalTool, + }; + }; + + it('should save the tool', async () => { + const { schoolExternalTool } = setup(); + + await uc.createSchoolExternalTool(schoolExternalTool.getProps()); + + expect(schoolExternalToolService.saveSchoolExternalTool).toHaveBeenCalledWith(schoolExternalTool); + }); + + it('should return the tool', async () => { + const { schoolExternalTool } = setup(); + + const result = await uc.createSchoolExternalTool(schoolExternalTool.getProps()); + + expect(result).toEqual(schoolExternalTool); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.ts new file mode 100644 index 00000000000..49f28dcc15b --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/uc/admin-api-school-external-tool.uc.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { SchoolExternalTool, SchoolExternalToolProps } from '../domain'; +import { SchoolExternalToolService } from '../service'; + +@Injectable() +export class AdminApiSchoolExternalToolUc { + constructor(private readonly schoolExternalToolService: SchoolExternalToolService) {} + + async createSchoolExternalTool(schoolExternalToolProps: SchoolExternalToolProps): Promise { + const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool(schoolExternalToolProps); + + const createdSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.saveSchoolExternalTool( + schoolExternalTool + ); + + return createdSchoolExternalTool; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/uc/index.ts b/apps/server/src/modules/tool/school-external-tool/uc/index.ts index be25bdc5699..0b0826460b6 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/index.ts @@ -1 +1,2 @@ export * from './school-external-tool.uc'; +export { AdminApiSchoolExternalToolUc } from './admin-api-school-external-tool.uc'; 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 30919794391..9305afbefef 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 @@ -8,7 +8,6 @@ import { Permission } from '@shared/domain/interface'; import { setupEntities, userFactory } from '@shared/testing'; import { School, SchoolService } from '@src/modules/school'; import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; -import { ContextExternalToolService } from '../../context-external-tool'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { schoolExternalToolFactory } from '../testing'; @@ -20,7 +19,6 @@ describe('SchoolExternalToolUc', () => { let uc: SchoolExternalToolUc; let schoolExternalToolService: DeepMocked; - let contextExternalToolService: DeepMocked; let schoolExternalToolValidationService: DeepMocked; let commonToolMetadataService: DeepMocked; let authorizationService: DeepMocked; @@ -35,10 +33,6 @@ describe('SchoolExternalToolUc', () => { provide: SchoolExternalToolService, useValue: createMock(), }, - { - provide: ContextExternalToolService, - useValue: createMock(), - }, { provide: SchoolExternalToolValidationService, useValue: createMock(), @@ -60,7 +54,6 @@ describe('SchoolExternalToolUc', () => { uc = module.get(SchoolExternalToolUc); schoolExternalToolService = module.get(SchoolExternalToolService); - contextExternalToolService = module.get(ContextExternalToolService); schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); commonToolMetadataService = module.get(CommonToolMetadataService); authorizationService = module.get(AuthorizationService); @@ -211,24 +204,16 @@ describe('SchoolExternalToolUc', () => { return { userId: user.id, - schoolExternalToolId: tool.id, + tool, }; }; - it('should call the courseExternalToolService', async () => { - const { userId, schoolExternalToolId } = setup(); - - await uc.deleteSchoolExternalTool(userId, schoolExternalToolId); - - expect(contextExternalToolService.deleteBySchoolExternalToolId).toHaveBeenCalledWith(schoolExternalToolId); - }); - - it('should call the schoolExternalToolService', async () => { - const { userId, schoolExternalToolId } = setup(); + it('should delete the tool', async () => { + const { userId, tool } = setup(); - await uc.deleteSchoolExternalTool(userId, schoolExternalToolId); + await uc.deleteSchoolExternalTool(userId, tool.id); - expect(schoolExternalToolService.deleteSchoolExternalToolById).toHaveBeenCalledWith(schoolExternalToolId); + expect(schoolExternalToolService.deleteSchoolExternalTool).toHaveBeenCalledWith(tool); }); }); }); 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 c7861f82823..d57bfafa5ea 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 @@ -5,7 +5,6 @@ import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { School, SchoolService } from '@src/modules/school'; import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; -import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool, SchoolExternalToolMetadata, SchoolExternalToolProps } from '../domain'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; @@ -14,7 +13,6 @@ import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; export class SchoolExternalToolUc { constructor( private readonly schoolExternalToolService: SchoolExternalToolService, - private readonly contextExternalToolService: ContextExternalToolService, private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, private readonly commonToolMetadataService: CommonToolMetadataService, @Inject(forwardRef(() => AuthorizationService)) private readonly authorizationService: AuthorizationService, @@ -33,6 +31,7 @@ export class SchoolExternalToolUc { await this.ensureSchoolPermissions(user, tools, school, context); } + return tools; } @@ -75,10 +74,7 @@ export class SchoolExternalToolUc { const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); this.authorizationService.checkPermission(user, school, context); - await Promise.all([ - this.contextExternalToolService.deleteBySchoolExternalToolId(schoolExternalToolId), - this.schoolExternalToolService.deleteSchoolExternalToolById(schoolExternalToolId), - ]); + await this.schoolExternalToolService.deleteSchoolExternalTool(schoolExternalTool); } async getSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { @@ -114,6 +110,7 @@ export class SchoolExternalToolUc { }); const saved: SchoolExternalTool = await this.schoolExternalToolService.saveSchoolExternalTool(updated); + return saved; } diff --git a/apps/server/src/modules/tool/tool-admin-api.module.ts b/apps/server/src/modules/tool/tool-admin-api.module.ts new file mode 100644 index 00000000000..dffcd76b99e --- /dev/null +++ b/apps/server/src/modules/tool/tool-admin-api.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { AdminApiContextExternalToolController } from './context-external-tool/controller'; +import { AdminApiContextExternalToolUc } from './context-external-tool/uc'; +import { AdminApiExternalToolController } from './external-tool/controller'; +import { ExternalToolRequestMapper, ExternalToolResponseMapper } from './external-tool/mapper'; +import { AdminApiExternalToolUc } from './external-tool/uc'; +import { AdminApiSchoolExternalToolController } from './school-external-tool/controller'; +import { AdminApiSchoolExternalToolUc } from './school-external-tool/uc'; +import { ToolModule } from './tool.module'; + +@Module({ + imports: [ToolModule], + controllers: [ + AdminApiExternalToolController, + AdminApiSchoolExternalToolController, + AdminApiContextExternalToolController, + ], + providers: [ + AdminApiExternalToolUc, + AdminApiSchoolExternalToolUc, + AdminApiContextExternalToolUc, + ExternalToolRequestMapper, + ExternalToolResponseMapper, + ], +}) +export class ToolAdminApiModule {} diff --git a/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts b/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts index 437ef9b5664..6e722c2c378 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiForbiddenResponse, @@ -19,7 +19,7 @@ import { } from './dto'; @ApiTags('Tool') -@Authenticate('jwt') +@JwtAuthentication() @Controller('tools') export class ToolLaunchController { constructor(private readonly toolLaunchUc: ToolLaunchUc) {} diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-external-uuid-strategy.service.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-external-uuid-strategy.service.ts new file mode 100644 index 00000000000..6d3de082343 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-external-uuid-strategy.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { CourseService } from '@modules/learnroom'; +import { Group, GroupService } from '@modules/group'; +import { Course } from '@shared/domain/entity'; +import { ToolContextType } from '../../../common/enum'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { AutoParameterStrategy } from './auto-parameter.strategy'; + +@Injectable() +export class AutoGroupExternalUuidStrategy implements AutoParameterStrategy { + constructor(private readonly courseService: CourseService, private readonly groupService: GroupService) {} + + async getValue( + _schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalToolLaunchable + ): Promise { + if (contextExternalTool.contextRef.type !== ToolContextType.COURSE) { + return undefined; + } + + const courseId = contextExternalTool.contextRef.id; + const course: Course = await this.courseService.findById(courseId); + + const syncedGroup: Group | undefined = await this.getSyncedGroup(course); + if (!syncedGroup) { + return undefined; + } + + const groupUuid = syncedGroup.externalSource?.externalId; + if (!groupUuid) { + return undefined; + } + + return groupUuid; + } + + private async getSyncedGroup(course: Course): Promise { + const syncedGroupId = course.syncedWithGroup?.id; + if (!syncedGroupId) { + return undefined; + } + + const syncedGroup = await this.groupService.findById(syncedGroupId); + return syncedGroup; + } +} diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-uuid.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-uuid.strategy.spec.ts new file mode 100644 index 00000000000..ffae6e12e7a --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-group-uuid.strategy.spec.ts @@ -0,0 +1,226 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { CourseService } from '@modules/learnroom'; +import { Course } from '@shared/domain/entity'; +import { Group, GroupService } from '@modules/group'; +import { GroupEntity } from '@modules/group/entity'; +import { courseFactory, groupEntityFactory, groupFactory, setupEntities } from '@shared/testing'; +import { ToolContextType } from '../../../common/enum'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { contextExternalToolFactory } from '../../../context-external-tool/testing'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; +import { AutoGroupExternalUuidStrategy } from './auto-group-external-uuid-strategy.service'; + +describe(AutoGroupExternalUuidStrategy.name, () => { + let module: TestingModule; + let strategy: AutoGroupExternalUuidStrategy; + + let courseService: DeepMocked; + let groupService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + AutoGroupExternalUuidStrategy, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: GroupService, + useValue: createMock(), + }, + ], + }).compile(); + + strategy = module.get(AutoGroupExternalUuidStrategy); + courseService = module.get(CourseService); + groupService = module.get(GroupService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getValue', () => { + describe('when the context is type course', () => { + const setupExternalTools = () => { + const courseId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { + id: courseId, + type: ToolContextType.COURSE, + }, + }); + return { courseId, schoolExternalTool, contextExternalTool }; + }; + + describe('when the course is synced with a group that has an external ID', () => { + const setup = () => { + const { courseId, schoolExternalTool, contextExternalTool } = setupExternalTools(); + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(); + const course: Course = courseFactory.buildWithId( + { + name: 'Synced Course', + syncedWithGroup: groupEntity, + }, + courseId + ); + + const groupDo: Group = groupFactory.build({ + id: groupEntity.id, + externalSource: { + externalId: groupEntity.externalSource?.externalId, + }, + }); + + courseService.findById.mockResolvedValue(course); + groupService.findById.mockResolvedValue(groupDo); + + return { + group: groupDo, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return the external ID from the synced group', async () => { + const { group, schoolExternalTool, contextExternalTool } = setup(); + + const result = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toEqual(group.externalSource?.externalId); + }); + }); + + describe('when the course is synced with a group that has no external ID', () => { + const setup = () => { + const { courseId, schoolExternalTool, contextExternalTool } = setupExternalTools(); + + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(); + const course: Course = courseFactory.buildWithId( + { + name: 'Synced Course', + syncedWithGroup: groupEntity, + }, + courseId + ); + + const groupDo: Group = groupFactory.build({ + id: groupEntity.id, + externalSource: { + externalId: undefined, + }, + }); + + courseService.findById.mockResolvedValue(course); + groupService.findById.mockResolvedValue(groupDo); + + return { + group: groupDo, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return undefined', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + const result = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toBeUndefined(); + }); + }); + + describe('when the course is not synced with any group', () => { + const setup = () => { + const { courseId, schoolExternalTool, contextExternalTool } = setupExternalTools(); + + const course: Course = courseFactory.buildWithId( + { + name: 'Synced Course', + syncedWithGroup: undefined, + }, + courseId + ); + + courseService.findById.mockResolvedValue(course); + + return { + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return undefined', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + const result = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('when the context is type board element', () => { + const setup = () => { + const courseId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { + id: courseId, + type: ToolContextType.BOARD_ELEMENT, + }, + }); + + return { + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return undefined', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + const result = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toBeUndefined(); + }); + }); + + describe('when the context is type media board', () => { + const setup = () => { + const courseId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { + id: courseId, + type: ToolContextType.MEDIA_BOARD, + }, + }); + + return { + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return undefined', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + const result = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts index 30f058547a1..d647726b1a7 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts @@ -3,4 +3,5 @@ export * from './auto-school-id.strategy'; export * from './auto-context-id.strategy'; export * from './auto-context-name.strategy'; export * from './auto-school-number.strategy'; +export * from './auto-group-external-uuid-strategy.service'; export { AutoMediumIdStrategy } from './auto-medium-id.strategy'; diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts index 843f11a511a..2bb4a05d7b0 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts @@ -26,6 +26,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -72,6 +73,7 @@ describe(AbstractLaunchStrategy.name, () => { let autoContextIdStrategy: DeepMocked; let autoContextNameStrategy: DeepMocked; let autoMediumIdStrategy: DeepMocked; + let autoGroupExternalUuidStrategy: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -97,6 +99,10 @@ describe(AbstractLaunchStrategy.name, () => { provide: AutoMediumIdStrategy, useValue: createMock(), }, + { + provide: AutoGroupExternalUuidStrategy, + useValue: createMock(), + }, ], }).compile(); @@ -107,6 +113,7 @@ describe(AbstractLaunchStrategy.name, () => { autoContextIdStrategy = module.get(AutoContextIdStrategy); autoContextNameStrategy = module.get(AutoContextNameStrategy); autoMediumIdStrategy = module.get(AutoMediumIdStrategy); + autoGroupExternalUuidStrategy = module.get(AutoGroupExternalUuidStrategy); }); afterAll(async () => { @@ -154,13 +161,13 @@ describe(AbstractLaunchStrategy.name, () => { const autoContextIdCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, - name: 'autoSchoolNumberParam', + name: 'autoContextIdParam', type: CustomParameterType.AUTO_CONTEXTID, }); const autoContextNameCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, - name: 'autoSchoolNumberParam', + name: 'autoContextNameParam', type: CustomParameterType.AUTO_CONTEXTNAME, }); const autoMediumIdCustomParameter = customParameterFactory.build({ @@ -169,6 +176,12 @@ describe(AbstractLaunchStrategy.name, () => { name: 'autoMediumIdParam', type: CustomParameterType.AUTO_MEDIUMID, }); + const autoGroupExternalUuidCustomParameter = customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.QUERY, + name: 'autoGroupExternalUuidParam', + type: CustomParameterType.AUTO_GROUP_EXTERNALUUID, + }); const externalTool: ExternalTool = externalToolFactory.build({ parameters: [ @@ -180,6 +193,7 @@ describe(AbstractLaunchStrategy.name, () => { autoContextIdCustomParameter, autoContextNameCustomParameter, autoMediumIdCustomParameter, + autoGroupExternalUuidCustomParameter, ], }); @@ -217,6 +231,7 @@ describe(AbstractLaunchStrategy.name, () => { autoContextIdStrategy.getValue.mockReturnValueOnce(mockedAutoValue); autoContextNameStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); autoMediumIdStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); + autoGroupExternalUuidStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); return { globalCustomParameter, @@ -226,6 +241,7 @@ describe(AbstractLaunchStrategy.name, () => { autoContextIdCustomParameter, autoContextNameCustomParameter, autoMediumIdCustomParameter, + autoGroupExternalUuidCustomParameter, schoolParameterEntry, contextParameterEntry, externalTool, @@ -246,6 +262,7 @@ describe(AbstractLaunchStrategy.name, () => { autoContextIdCustomParameter, autoContextNameCustomParameter, autoMediumIdCustomParameter, + autoGroupExternalUuidCustomParameter, schoolParameterEntry, externalTool, schoolExternalTool, @@ -306,6 +323,11 @@ describe(AbstractLaunchStrategy.name, () => { value: mockedAutoValue, location: PropertyLocation.QUERY, }, + { + name: autoGroupExternalUuidCustomParameter.name, + value: mockedAutoValue, + location: PropertyLocation.QUERY, + }, { name: concreteConfigParameter.name, value: concreteConfigParameter.value, diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts index 9abfb4cf78c..ca5f5535571 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts @@ -16,6 +16,7 @@ import { AutoParameterStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; import { ToolLaunchStrategy } from './tool-launch-strategy.interface'; @@ -29,7 +30,8 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { autoSchoolNumberStrategy: AutoSchoolNumberStrategy, autoContextIdStrategy: AutoContextIdStrategy, autoContextNameStrategy: AutoContextNameStrategy, - autoMediumIdStrategy: AutoMediumIdStrategy + autoMediumIdStrategy: AutoMediumIdStrategy, + autoGroupExternalUuidStrategy: AutoGroupExternalUuidStrategy ) { this.autoParameterStrategyMap = new Map([ [CustomParameterType.AUTO_SCHOOLID, autoSchoolIdStrategy], @@ -37,6 +39,7 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { [CustomParameterType.AUTO_CONTEXTID, autoContextIdStrategy], [CustomParameterType.AUTO_CONTEXTNAME, autoContextNameStrategy], [CustomParameterType.AUTO_MEDIUMID, autoMediumIdStrategy], + [CustomParameterType.AUTO_GROUP_EXTERNALUUID, autoGroupExternalUuidStrategy], ]); } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts index e6d3780b6a9..db7bda486d0 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts @@ -13,6 +13,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { BasicToolLaunchStrategy } from './basic-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -45,6 +46,10 @@ describe('BasicToolLaunchStrategy', () => { provide: AutoMediumIdStrategy, useValue: createMock(), }, + { + provide: AutoGroupExternalUuidStrategy, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts index 39464dbafd3..05fecd75d56 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts @@ -23,6 +23,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; @@ -72,6 +73,10 @@ describe('Lti11ToolLaunchStrategy', () => { provide: AutoMediumIdStrategy, useValue: createMock(), }, + { + provide: AutoGroupExternalUuidStrategy, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts index 30a601f6025..948df83c295 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts @@ -16,6 +16,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; @@ -31,14 +32,16 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { autoSchoolNumberStrategy: AutoSchoolNumberStrategy, autoContextIdStrategy: AutoContextIdStrategy, autoContextNameStrategy: AutoContextNameStrategy, - autoMediumIdStrategy: AutoMediumIdStrategy + autoMediumIdStrategy: AutoMediumIdStrategy, + autoGroupExternalUuidStrategy: AutoGroupExternalUuidStrategy ) { super( autoSchoolIdStrategy, autoSchoolNumberStrategy, autoContextIdStrategy, autoContextNameStrategy, - autoMediumIdStrategy + autoMediumIdStrategy, + autoGroupExternalUuidStrategy ); } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts index 608ef0597ab..dcda4d88a86 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts @@ -13,6 +13,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { OAuth2ToolLaunchStrategy } from './oauth2-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -45,6 +46,10 @@ describe('OAuth2ToolLaunchStrategy', () => { provide: AutoMediumIdStrategy, useValue: createMock(), }, + { + provide: AutoGroupExternalUuidStrategy, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts index 9c1504321a9..15344278bcd 100644 --- a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts +++ b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts @@ -3,6 +3,7 @@ import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; import { PseudonymModule } from '@modules/pseudonym'; import { UserModule } from '@modules/user'; +import { GroupModule } from '@modules/group'; import { forwardRef, Module } from '@nestjs/common'; import { CommonToolModule } from '../common'; import { ContextExternalToolModule } from '../context-external-tool'; @@ -15,6 +16,7 @@ import { AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoGroupExternalUuidStrategy, } from './service/auto-parameter-strategy'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy } from './service/launch-strategy'; @@ -29,6 +31,7 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat forwardRef(() => PseudonymModule), // i do not like this solution, the root problem is on other place but not detectable for me LearnroomModule, BoardModule, + GroupModule, ], providers: [ ToolLaunchService, @@ -41,6 +44,7 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat AutoSchoolIdStrategy, AutoSchoolNumberStrategy, AutoMediumIdStrategy, + AutoGroupExternalUuidStrategy, ], exports: [ToolLaunchService], }) diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts index 21b9b6b005a..0ff4a8c1320 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts @@ -7,7 +7,14 @@ import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, RoleName } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; -import { roleFactory, schoolEntityFactory, systemEntityFactory, TestApiClient, userFactory } from '@shared/testing'; +import { + roleFactory, + schoolEntityFactory, + systemEntityFactory, + TestApiClient, + userFactory, + userLoginMigrationFactory, +} from '@shared/testing'; import { accountFactory } from '@src/modules/account/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -112,11 +119,15 @@ describe('ImportUser Controller Populate (API)', () => { describe('when users school has no external id', () => { const setup = async () => { const { account, school, system } = await authenticatedUser([Permission.IMPORT_USER_MIGRATE], [], false); - const loggedInClient = await testApiClient.login(account); + school.externalId = undefined; + const config: ServerConfig = serverConfig(); config.FEATURE_USER_MIGRATION_SYSTEM_ID = system.id; - school.externalId = undefined; + const userLoginMigration = userLoginMigrationFactory.buildWithId({ school }); + await em.persistAndFlush([userLoginMigration]); + + const loggedInClient = await testApiClient.login(account); return { loggedInClient }; }; @@ -144,6 +155,9 @@ describe('ImportUser Controller Populate (API)', () => { config.FEATURE_USER_MIGRATION_ENABLED = true; config.FEATURE_USER_MIGRATION_SYSTEM_ID = system.id; + const userLoginMigration = userLoginMigrationFactory.buildWithId({ school }); + await em.persistAndFlush([userLoginMigration]); + axiosMock.onPost(/(.*)\/token/).reply(HttpStatus.OK, { id_token: 'idToken', refresh_token: 'refreshToken', diff --git a/apps/server/src/modules/user-import/controller/dto/import-user.response.ts b/apps/server/src/modules/user-import/controller/dto/import-user.response.ts index ad49be2bf53..2d87caf9e7b 100644 --- a/apps/server/src/modules/user-import/controller/dto/import-user.response.ts +++ b/apps/server/src/modules/user-import/controller/dto/import-user.response.ts @@ -12,6 +12,7 @@ export class ImportUserResponse { this.lastName = props.lastName; this.roleNames = props.roleNames; this.classNames = props.classNames; + this.externalRoleNames = props.externalRoleNames; if (props.match != null) this.match = props.match; if (props.flagged === true) this.flagged = true; } @@ -59,6 +60,9 @@ export class ImportUserResponse { // eslint-disable-next-line @typescript-eslint/no-inferrable-types @ApiProperty({ description: 'manual flag to apply it as filter' }) flagged: boolean = false; + + @ApiPropertyOptional({ description: 'exact user roles from the external system' }) + externalRoleNames?: string[]; } export class ImportUserListResponse extends PaginationResponse { diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.ts b/apps/server/src/modules/user-import/controller/import-user.controller.ts index 8f82b461c50..edddcb8ffd4 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -29,7 +29,7 @@ import { } from './dto'; @ApiTags('UserImport') -@Authenticate('jwt') +@JwtAuthentication() @Controller('user/import') export class ImportUserController { constructor(private readonly userImportUc: UserImportUc, private readonly userImportFetchUc: UserImportFetchUc) {} diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts index 6b4929ce0b9..5866aa22e61 100644 --- a/apps/server/src/modules/user-import/loggable/index.ts +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -11,3 +11,4 @@ export { UserMigrationIsNotEnabled } from './user-migration-not-enable.loggable' export { UserMigrationIsNotEnabledLoggableException } from './user-migration-not-enable-loggable-exception'; export { UserMigrationCanceledLoggable } from './user-migration-canceled.loggable'; export { UserAlreadyMigratedLoggable } from './user-already-migrated.loggable'; +export { UserLoginMigrationNotActiveLoggableException } from './user-login-migration-not-active.loggable-exception'; diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts index 881b8fc6c4b..44dc7fc8295 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts @@ -70,6 +70,7 @@ describe('[ImportUserMapper]', () => { roleNames: [RoleName.STUDENT, RoleName.TEACHER, RoleName.ADMINISTRATOR], ldapDn: 'uid=Eva_Rak123,foo=bar,...', flagged: true, + externalRoleNames: ['ext-student', 'ext-teacher', 'ext-admin'], }); const result = ImportUserMapper.mapToResponse(importUser); const expected = { @@ -80,6 +81,7 @@ describe('[ImportUserMapper]', () => { lastName: 'Rakäthe', roleNames: ['student', 'teacher', 'admin'], classNames: ['firstClass'], + externalRoleNames: ['ext-student', 'ext-teacher', 'ext-admin'], }; expect(result).toEqual(expected); }); diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts index 767f09f978f..a36d6b0fa56 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts @@ -40,6 +40,7 @@ export class ImportUserMapper { roleNames: importUser.roleNames?.map((role) => RoleNameMapper.mapToResponse(role)), classNames: importUser.classNames, flagged: importUser.flagged, + externalRoleNames: importUser.externalRoleNames, }); if (importUser.user != null && importUser.matchedBy) { const { user } = importUser; diff --git a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts index 4f4f482b8e8..85b2b26ee11 100644 --- a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts @@ -29,6 +29,7 @@ export class SchulconnexImportUserMapper { roleNames: ImportUser.isImportUserRole(role) ? [role] : [], email: `${externalUser.person.name.vorname}.${externalUser.person.name.familienname}.${externalUser.pid}@schul-cloud.org`, classNames: groups ? SchulconnexResponseMapper.mapToGroupNameList(groups) : [], + externalRoleNames: [externalUser.personenkontexte[0].rolle], }); return importUser; diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts index 2c9d6e8d848..ddac798c365 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts @@ -72,14 +72,15 @@ describe(SchulconnexFetchImportUsersService.name, () => { email: `${externalUserData.person.name.vorname}.${externalUserData.person.name.familienname}.${externalUserData.pid}@schul-cloud.org`, roleNames: [RoleName.ADMINISTRATOR], classNames: undefined, + externalRoleNames: ['admin'], }); describe('getData', () => { describe('when fetching the data', () => { const setup = () => { const externalUserData: SchulconnexResponse = schulconnexResponseFactory.build(); - const system: System = systemFactory.build(); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build({ id: systemEntity.id }); const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], externalId: 'externalSchoolId', @@ -142,26 +143,27 @@ describe(SchulconnexFetchImportUsersService.name, () => { describe('when the user was not migrated yet', () => { const setup = () => { const externalUserData: SchulconnexResponse = schulconnexResponseFactory.build(); - const system: SystemEntity = systemEntityFactory.buildWithId(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build({ id: systemEntity.id }); const school: SchoolEntity = schoolEntityFactory.buildWithId({ - systems: [system], + systems: [systemEntity], externalId: 'externalSchoolId', }); - const importUser: ImportUser = createImportUser(externalUserData, school, system); + const importUser: ImportUser = createImportUser(externalUserData, school, systemEntity); const migratedUser: UserDO = userDoFactory.build({ externalId: externalUserData.pid }); userService.findByExternalId.mockResolvedValueOnce(null); return { - systemId: system.id, + system, importUsers: [importUser], migratedUser, }; }; it('should return the import users', async () => { - const { systemId, importUsers } = setup(); + const { system, importUsers } = setup(); - const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, systemId); + const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, system); expect(result).toHaveLength(1); }); @@ -170,25 +172,26 @@ describe(SchulconnexFetchImportUsersService.name, () => { describe('when the user already was migrated', () => { const setup = () => { const externalUserData: SchulconnexResponse = schulconnexResponseFactory.build(); - const system: SystemEntity = systemEntityFactory.buildWithId(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build({ id: systemEntity.id }); const school: SchoolEntity = schoolEntityFactory.buildWithId({ - systems: [system], + systems: [systemEntity], externalId: 'externalSchoolId', }); - const importUser: ImportUser = createImportUser(externalUserData, school, system); + const importUser: ImportUser = createImportUser(externalUserData, school, systemEntity); const migratedUser: UserDO = userDoFactory.build({ externalId: externalUserData.pid }); userService.findByExternalId.mockResolvedValueOnce(migratedUser); return { - systemId: system.id, + system, importUsers: [importUser], }; }; it('should return an empty array', async () => { - const { systemId, importUsers } = setup(); + const { system, importUsers } = setup(); - const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, systemId); + const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, system); expect(result).toHaveLength(0); }); diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts index 2a49438a7c3..26c4e0c395f 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts @@ -5,7 +5,6 @@ import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject'; import { ImportUser, SchoolEntity } from '@shared/domain/entity'; -import { EntityId } from '@shared/domain/types'; import { UserImportSchoolExternalIdMissingLoggableException } from '../loggable'; import { SchulconnexImportUserMapper } from '../mapper'; @@ -38,11 +37,11 @@ export class SchulconnexFetchImportUsersService { return mappedImportUsers; } - public async filterAlreadyMigratedUser(importUsers: ImportUser[], systemId: EntityId): Promise { + public async filterAlreadyMigratedUser(importUsers: ImportUser[], system: System): Promise { const filteredUsers: ImportUser[] = ( await Promise.all( importUsers.map(async (importUser: ImportUser): Promise => { - const foundUser: UserDO | null = await this.userService.findByExternalId(importUser.externalId, systemId); + const foundUser: UserDO | null = await this.userService.findByExternalId(importUser.externalId, system.id); return foundUser ? null : importUser; }) ) diff --git a/apps/server/src/modules/user-import/service/user-import.service.spec.ts b/apps/server/src/modules/user-import/service/user-import.service.spec.ts index 87d683f2868..36c46aa92ef 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.spec.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.spec.ts @@ -1,24 +1,24 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; import { System, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; import { ImportUserRepo } from '@shared/repo'; import { - cleanupCollections, importUserFactory, legacySchoolDoFactory, schoolEntityFactory, setupEntities, systemFactory, userFactory, + userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { UserMigrationCanceledLoggable, UserMigrationIsNotEnabled } from '../loggable'; @@ -28,20 +28,15 @@ import { UserImportService } from './user-import.service'; describe(UserImportService.name, () => { let module: TestingModule; let service: UserImportService; - let em: EntityManager; + let configService: DeepMocked; let importUserRepo: DeepMocked; let systemService: DeepMocked; let userService: DeepMocked; let logger: DeepMocked; let schoolService: DeepMocked; - const config: UserImportConfig = { - FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), - FEATURE_USER_MIGRATION_ENABLED: true, - FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, - IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 8000, - }; + let config: UserImportConfig; beforeAll(async () => { await setupEntities(); @@ -52,9 +47,7 @@ describe(UserImportService.name, () => { UserImportService, { provide: ConfigService, - useValue: { - get: jest.fn().mockImplementation((key: keyof UserImportConfig) => config[key]), - }, + useValue: createMock(), }, { provide: ImportUserRepo, @@ -80,7 +73,7 @@ describe(UserImportService.name, () => { }).compile(); service = module.get(UserImportService); - em = module.get(EntityManager); + configService = module.get(ConfigService); importUserRepo = module.get(ImportUserRepo); systemService = module.get(SystemService); userService = module.get(UserService); @@ -88,12 +81,23 @@ describe(UserImportService.name, () => { schoolService = module.get(LegacySchoolService); }); - afterAll(async () => { - await module.close(); + beforeEach(() => { + config = { + FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), + FEATURE_USER_MIGRATION_ENABLED: true, + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, + IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 8000, + }; + + configService.get.mockImplementation((key: keyof UserImportConfig) => config[key]); }); - beforeEach(async () => { - await cleanupCollections(em); + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); }); describe('saveImportUsers', () => { @@ -213,6 +217,7 @@ describe(UserImportService.name, () => { describe('when all users have unique names', () => { const setup = () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ schoolId: school.id }); const user1: User = userFactory.buildWithId({ firstName: 'First1', lastName: 'Last1' }); const user2: User = userFactory.buildWithId({ firstName: 'First2', lastName: 'Last2' }); const importUser1: ImportUser = importUserFactory.buildWithId({ @@ -234,13 +239,14 @@ describe(UserImportService.name, () => { user2, importUser1, importUser2, + userLoginMigration, }; }; it('should return all users as auto matched', async () => { - const { user1, user2, importUser1, importUser2 } = setup(); + const { user1, user2, importUser1, importUser2, userLoginMigration } = setup(); - const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2], userLoginMigration); expect(result).toEqual([ { ...importUser1, user: user1, matchedBy: MatchCreator.AUTO }, @@ -252,6 +258,7 @@ describe(UserImportService.name, () => { describe('when the imported users have the same names', () => { const setup = () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ schoolId: school.id }); const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); const importUser1: ImportUser = importUserFactory.buildWithId({ school, @@ -271,13 +278,14 @@ describe(UserImportService.name, () => { user1, importUser1, importUser2, + userLoginMigration, }; }; it('should return the users without a match', async () => { - const { importUser1, importUser2 } = setup(); + const { importUser1, importUser2, userLoginMigration } = setup(); - const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2], userLoginMigration); expect(result).toEqual([importUser1, importUser2]); }); @@ -286,6 +294,7 @@ describe(UserImportService.name, () => { describe('when existing users in svs have the same names', () => { const setup = () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ schoolId: school.id }); const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); const user2: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); const importUser1: ImportUser = importUserFactory.buildWithId({ @@ -301,13 +310,14 @@ describe(UserImportService.name, () => { user1, user2, importUser1, + userLoginMigration, }; }; it('should return the users without a match', async () => { - const { importUser1 } = setup(); + const { importUser1, userLoginMigration } = setup(); - const result: ImportUser[] = await service.matchUsers([importUser1]); + const result: ImportUser[] = await service.matchUsers([importUser1], userLoginMigration); expect(result).toEqual([importUser1]); }); @@ -316,6 +326,7 @@ describe(UserImportService.name, () => { describe('when import users have the same name ', () => { const setup = () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ schoolId: school.id }); const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); const importUser1: ImportUser = importUserFactory.buildWithId({ school, @@ -335,13 +346,47 @@ describe(UserImportService.name, () => { user1, importUser1, importUser2, + userLoginMigration, }; }; it('should return the users without a match', async () => { - const { importUser1, importUser2 } = setup(); + const { importUser1, importUser2, userLoginMigration } = setup(); + + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2], userLoginMigration); + + result.forEach((importUser) => expect(importUser.matchedBy).toBeUndefined()); + }); + }); + + describe('when a user is already migarted', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ schoolId: school.id }); + const user1: User = userFactory.buildWithId({ + firstName: 'First', + lastName: 'Last', + lastLoginSystemChange: userLoginMigration.startedAt, + }); + const importUser1: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + + return { + user1, + importUser1, + userLoginMigration, + }; + }; + + it('should return the user without a match', async () => { + const { importUser1, userLoginMigration } = setup(); - const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + const result: ImportUser[] = await service.matchUsers([importUser1], userLoginMigration); result.forEach((importUser) => expect(importUser.matchedBy).toBeUndefined()); }); diff --git a/apps/server/src/modules/user-import/service/user-import.service.ts b/apps/server/src/modules/user-import/service/user-import.service.ts index db3fbeb5c70..d63fdb29c2d 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.ts @@ -3,7 +3,7 @@ import { System, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; import { ImportUserRepo } from '@shared/repo'; @@ -44,7 +44,7 @@ export class UserImportService { } } - public async matchUsers(importUsers: ImportUser[]): Promise { + public async matchUsers(importUsers: ImportUser[], userLoginMigration: UserLoginMigrationDO): Promise { const importUserMap: Map = new Map(); importUsers.forEach((importUser) => { @@ -55,16 +55,20 @@ export class UserImportService { const matchedImportUsers: ImportUser[] = await Promise.all( importUsers.map(async (importUser: ImportUser): Promise => { - const user: User[] = await this.userService.findUserBySchoolAndName( + const users: User[] = await this.userService.findUserBySchoolAndName( importUser.school.id, importUser.firstName, importUser.lastName ); + const unmigratedUsers: User[] = users.filter( + (user: User) => !user.lastLoginSystemChange || user.lastLoginSystemChange < userLoginMigration.startedAt + ); + const key = `${importUser.school.id}_${importUser.firstName}_${importUser.lastName}`; - if (user.length === 1 && importUserMap.get(key) === 1) { - importUser.user = user[0]; + if (users.length === 1 && unmigratedUsers.length === 1 && importUserMap.get(key) === 1) { + importUser.user = unmigratedUsers[0]; importUser.matchedBy = MatchCreator.AUTO; } diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts index 8394306979f..fb34bc9c7b9 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts @@ -1,14 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationService } from '@modules/authorization'; -import { ConfigService } from '@nestjs/config'; -import { System } from '@modules/system'; +import { System, SystemService } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; import { ImportUser, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { importUserFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; -import { UserMigrationIsNotEnabledLoggableException } from '../loggable'; +import { + importUserFactory, + setupEntities, + systemEntityFactory, + systemFactory, + userFactory, + userLoginMigrationDOFactory, +} from '@shared/testing'; +import { UserLoginMigrationService } from '../../user-login-migration'; +import { UserLoginMigrationNotActiveLoggableException, UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; import { UserImportFetchUc } from './user-import-fetch.uc'; @@ -17,16 +26,14 @@ describe(UserImportFetchUc.name, () => { let module: TestingModule; let uc: UserImportFetchUc; + let configService: DeepMocked; let schulconnexFetchImportUsersService: DeepMocked; let authorizationService: DeepMocked; let userImportService: DeepMocked; + let userLoginMigrationService: DeepMocked; + let systemService: DeepMocked; - const config: UserImportConfig = { - FEATURE_USER_MIGRATION_ENABLED: true, - FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), - FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, - IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 0, - }; + let config: UserImportConfig; beforeAll(async () => { await setupEntities(); @@ -36,9 +43,7 @@ describe(UserImportFetchUc.name, () => { UserImportFetchUc, { provide: ConfigService, - useValue: { - get: jest.fn().mockImplementation((key: keyof UserImportConfig) => config[key]), - }, + useValue: createMock(), }, { provide: SchulconnexFetchImportUsersService, @@ -52,19 +57,35 @@ describe(UserImportFetchUc.name, () => { provide: UserImportService, useValue: createMock(), }, + { + provide: UserLoginMigrationService, + useValue: createMock(), + }, + { + provide: SystemService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(UserImportFetchUc); + configService = module.get(ConfigService); schulconnexFetchImportUsersService = module.get(SchulconnexFetchImportUsersService); authorizationService = module.get(AuthorizationService); userImportService = module.get(UserImportService); + userLoginMigrationService = module.get(UserLoginMigrationService); + systemService = module.get(SystemService); }); beforeEach(() => { - config.FEATURE_USER_MIGRATION_ENABLED = true; - config.FEATURE_USER_MIGRATION_SYSTEM_ID = new ObjectId().toHexString(); - config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; + config = { + FEATURE_USER_MIGRATION_ENABLED: true, + FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, + IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 0, + }; + + configService.get.mockImplementation((key: keyof UserImportConfig) => config[key]); }); afterAll(async () => { @@ -78,26 +99,29 @@ describe(UserImportFetchUc.name, () => { describe('fetchImportUsers', () => { describe('when fetching and matching users', () => { const setup = () => { - const system: SystemEntity = systemEntityFactory.buildWithId( - undefined, - config.FEATURE_USER_MIGRATION_SYSTEM_ID - ); - const systemDo: System = systemFactory.build({ id: system.id }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build({ id: systemEntity.id }); const user: User = userFactory.buildWithId(); const importUser: ImportUser = importUserFactory.build({ - system, + system: systemEntity, + }); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + targetSystemId: system.id, }); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - userImportService.getMigrationSystem.mockResolvedValueOnce(systemDo); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + systemService.findByIdOrFail.mockResolvedValueOnce(system); schulconnexFetchImportUsersService.getData.mockResolvedValueOnce([importUser]); schulconnexFetchImportUsersService.filterAlreadyMigratedUser.mockResolvedValueOnce([importUser]); userImportService.matchUsers.mockResolvedValueOnce([importUser]); return { user, + systemEntity, system, importUser, + userLoginMigration, }; }; @@ -114,18 +138,15 @@ describe(UserImportFetchUc.name, () => { await uc.populateImportUsers(user.id); - expect(schulconnexFetchImportUsersService.filterAlreadyMigratedUser).toHaveBeenCalledWith( - [importUser], - system.id - ); + expect(schulconnexFetchImportUsersService.filterAlreadyMigratedUser).toHaveBeenCalledWith([importUser], system); }); it('should match the users', async () => { - const { user, importUser } = setup(); + const { user, importUser, userLoginMigration } = setup(); await uc.populateImportUsers(user.id); - expect(userImportService.matchUsers).toHaveBeenCalledWith([importUser]); + expect(userImportService.matchUsers).toHaveBeenCalledWith([importUser], userLoginMigration); }); it('should delete all existing imported users of the school', async () => { @@ -146,27 +167,51 @@ describe(UserImportFetchUc.name, () => { }); }); - describe('when the migration feature is not enabled', () => { + describe('when the school has not started the migration', () => { const setup = () => { - config.FEATURE_USER_MIGRATION_ENABLED = false; + const user: User = userFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + + return { + user, + }; + }; + + it('should throw error', async () => { + const { user } = setup(); + + await expect(uc.populateImportUsers(user.id)).rejects.toThrow(UserLoginMigrationNotActiveLoggableException); + }); + }); + describe('when the school has already closed the migration', () => { + const setup = () => { const user: User = userFactory.buildWithId(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + closedAt: new Date(), + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); return { user, + userLoginMigration, }; }; - it('should throw an error', async () => { + it('should throw error', async () => { const { user } = setup(); - await expect(uc.populateImportUsers(user.id)).rejects.toThrow(UserMigrationIsNotEnabledLoggableException); + await expect(uc.populateImportUsers(user.id)).rejects.toThrow(UserLoginMigrationNotActiveLoggableException); }); }); - describe('when the target system id is not defined', () => { + describe('when the migration feature is not enabled', () => { const setup = () => { - config.FEATURE_USER_MIGRATION_SYSTEM_ID = ''; + config.FEATURE_USER_MIGRATION_ENABLED = false; const user: User = userFactory.buildWithId(); diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts index 743e942449d..839cf83b56a 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts @@ -1,11 +1,13 @@ import { AuthorizationService } from '@modules/authorization'; -import { System } from '@modules/system'; +import { System, SystemService } from '@modules/system'; +import { UserLoginMigrationService } from '@modules/user-login-migration'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; import { ImportUser, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { UserMigrationIsNotEnabledLoggableException } from '../loggable'; +import { UserLoginMigrationNotActiveLoggableException, UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -15,36 +17,42 @@ export class UserImportFetchUc { private readonly configService: ConfigService, private readonly schulconnexFetchImportUsersService: SchulconnexFetchImportUsersService, private readonly authorizationService: AuthorizationService, - private readonly userImportService: UserImportService + private readonly userImportService: UserImportService, + private readonly userLoginMigrationService: UserLoginMigrationService, + private readonly systemService: SystemService ) {} public async populateImportUsers(currentUserId: EntityId): Promise { - this.checkMigrationEnabled(currentUserId); + if (!this.configService.get('FEATURE_USER_MIGRATION_ENABLED')) { + throw new UserMigrationIsNotEnabledLoggableException(currentUserId); + } const user: User = await this.authorizationService.getUserWithPermissions(currentUserId); this.authorizationService.checkAllPermissions(user, [Permission.IMPORT_USER_MIGRATE]); - const system: System = await this.userImportService.getMigrationSystem(); + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + user.school.id + ); + + if (!userLoginMigration || userLoginMigration?.closedAt) { + throw new UserLoginMigrationNotActiveLoggableException(user.school.id); + } + + const system: System = await this.systemService.findByIdOrFail(userLoginMigration.targetSystemId); const fetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.getData(user.school, system); const filteredFetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.filterAlreadyMigratedUser( fetchedData, - this.configService.get('FEATURE_USER_MIGRATION_SYSTEM_ID') + system ); - const matchedImportUsers: ImportUser[] = await this.userImportService.matchUsers(filteredFetchedData); + const matchedImportUsers: ImportUser[] = await this.userImportService.matchUsers( + filteredFetchedData, + userLoginMigration + ); await this.userImportService.deleteImportUsersBySchool(user.school); await this.userImportService.saveImportUsers(matchedImportUsers); } - - private checkMigrationEnabled(userId: EntityId): void { - if ( - !this.configService.get('FEATURE_USER_MIGRATION_ENABLED') || - !this.configService.get('FEATURE_USER_MIGRATION_SYSTEM_ID') - ) { - throw new UserMigrationIsNotEnabledLoggableException(userId); - } - } } diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts index b278384b3ac..8b17eaa6440 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiForbiddenResponse, @@ -14,7 +14,7 @@ import { UserIdParams } from './dto'; @ApiTags('UserLoginMigration Rollback') @Controller('user-login-migrations') -@Authenticate('jwt') +@JwtAuthentication() export class UserLoginMigrationRollbackController { constructor(private readonly userLoginMigrationRollbackUc: UserLoginMigrationRollbackUc) {} 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 e5b48fdc05e..75a2b0271b8 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,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser, JWT } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JWT, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiCreatedResponse, @@ -41,7 +41,7 @@ import { @ApiTags('UserLoginMigration') @Controller('user-login-migrations') -@Authenticate('jwt') +@JwtAuthentication() export class UserLoginMigrationController { constructor( private readonly userLoginMigrationUc: UserLoginMigrationUc, 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 b2e1478e37f..4a55101f4b7 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 @@ -1,4 +1,4 @@ -import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { AuthenticationModule } from '@modules/authentication'; import { AuthorizationModule } from '@modules/authorization'; import { LegacySchoolModule } from '@modules/legacy-school'; import { OauthModule } from '@modules/oauth'; diff --git a/apps/server/src/modules/user/controller/admin-api-user.controller.ts b/apps/server/src/modules/user/controller/admin-api-user.controller.ts index 701b34bad20..4268172e220 100644 --- a/apps/server/src/modules/user/controller/admin-api-user.controller.ts +++ b/apps/server/src/modules/user/controller/admin-api-user.controller.ts @@ -1,12 +1,12 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { AdminApiUserUc } from '../uc'; import { AdminApiUserCreateBodyParams } from './dto/admin-api-user-create.body.params'; import { AdminApiUserCreateResponse } from './dto/admin-api-user-create.response.dto'; @ApiTags('AdminApiUsers') -@UseGuards(AuthGuard('api-key')) +@UseGuards(ApiKeyGuard) @Controller('/admin/users') export class AdminApiUsersController { constructor(private readonly uc: AdminApiUserUc) {} diff --git a/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts b/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts index db04a062561..dff8169da9c 100644 --- a/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts +++ b/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts @@ -1,3 +1,4 @@ +import { ApiKeyGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -7,7 +8,6 @@ import { TestApiClient, schoolEntityFactory } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { AdminApiServerTestModule } from '@src/modules/server/admin-api.server.module'; import { nanoid } from 'nanoid'; -import { AuthGuard } from '@nestjs/passport'; import { AdminApiUserCreateResponse } from '../dto/admin-api-user-create.response.dto'; const baseRouteName = '/admin/users'; @@ -22,7 +22,7 @@ describe('Admin API - Users (API)', () => { const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts b/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts index fb8cd5c4a83..e8d6debf0e1 100644 --- a/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts +++ b/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts @@ -2,8 +2,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ApiValidationError } from '@shared/common'; import { User } from '@shared/domain/entity'; diff --git a/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts b/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts index 9182109b3e2..df8e9d588f7 100644 --- a/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts +++ b/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts @@ -5,8 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; import request from 'supertest'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ResolvedUserResponse } from '@modules/user/controller/dto'; import { ApiValidationError } from '@shared/common'; diff --git a/apps/server/src/modules/user/controller/user.controller.ts b/apps/server/src/modules/user/controller/user.controller.ts index 1d3739b3144..d555a924c5a 100644 --- a/apps/server/src/modules/user/controller/user.controller.ts +++ b/apps/server/src/modules/user/controller/user.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, Patch } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ResolvedUserMapper } from '../mapper'; @@ -6,7 +6,7 @@ import { UserUc } from '../uc'; import { ChangeLanguageParams, ResolvedUserResponse, SuccessfulResponse } from './dto'; @ApiTags('User') -@Authenticate('jwt') +@JwtAuthentication() @Controller('user') export class UserController { constructor(private readonly userUc: UserUc) {} diff --git a/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts b/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts index cc7814b663a..999a7a15cd0 100644 --- a/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts +++ b/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts @@ -1,13 +1,13 @@ -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; -import { Authenticate, CurrentUser, ICurrentUser } from '../../../authentication'; import { RequestedRoleEnum } from '../enum'; -import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; import { UsersAdminApiUc } from '../uc'; +import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; @ApiTags('AdminStudents') -@Authenticate('jwt') +@JwtAuthentication() @Controller('users/admin/students') export class AdminApiStudentsController { constructor(private readonly uc: UsersAdminApiUc) {} diff --git a/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts b/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts index 8b3ea3cabd1..d34e01dcc10 100644 --- a/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts +++ b/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts @@ -1,13 +1,13 @@ -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; -import { Authenticate, CurrentUser, ICurrentUser } from '../../../authentication'; import { RequestedRoleEnum } from '../enum'; -import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; import { UsersAdminApiUc } from '../uc'; +import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; @ApiTags('AdminTeachers') -@Authenticate('jwt') +@JwtAuthentication() @Controller('users/admin/teachers') export class AdminApiTeachersController { constructor(private readonly uc: UsersAdminApiUc) {} diff --git a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts index 14e45fefcd4..adc58ec3839 100644 --- a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts +++ b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts @@ -1,3 +1,4 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; @@ -13,8 +14,6 @@ import { } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { accountFactory } from '@src/modules/account/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 { Request } from 'express'; import request from 'supertest'; diff --git a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts index 587b93d71dd..dec6fcf809d 100644 --- a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts +++ b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts @@ -1,3 +1,4 @@ +import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/core'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -12,8 +13,6 @@ import { } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { accountFactory } from '@src/modules/account/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 { Request } from 'express'; import request from 'supertest'; diff --git a/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.spec.ts b/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.spec.ts index 01a62786582..b0bc50275c8 100644 --- a/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.spec.ts +++ b/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.spec.ts @@ -1,8 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; import { Test, TestingModule } from '@nestjs/testing'; import { VideoConferenceScope } from '@shared/domain/interface'; +import { currentUserFactory } from '@shared/testing'; import { BBBBaseResponse, BBBCreateResponse } from '../bbb'; import { defaultVideoConferenceOptions } from '../interface'; import { VideoConferenceDeprecatedUc } from '../uc'; @@ -18,7 +17,7 @@ describe('VideoConferenceDeprecatedController', () => { let controller: VideoConferenceDeprecatedController; let videoConferenceUc: DeepMocked; - const currentUser: ICurrentUser = { userId: new ObjectId().toHexString() } as ICurrentUser; + const currentUser = currentUserFactory.build(); beforeAll(async () => { module = await Test.createTestingModule({ diff --git a/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.ts b/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.ts index d32273b98f7..1f64a6be850 100644 --- a/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.ts +++ b/apps/server/src/modules/video-conference/controller/video-conference-deprecated.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { BadRequestException, Body, @@ -27,7 +27,7 @@ import { * This controller is deprecated. Please use {@link VideoConferenceController} instead. */ @ApiTags('VideoConference') -@Authenticate('jwt') +@JwtAuthentication() @Controller('videoconference') export class VideoConferenceDeprecatedController { constructor(private readonly videoConferenceUc: VideoConferenceDeprecatedUc) {} diff --git a/apps/server/src/modules/video-conference/controller/video-conference.controller.ts b/apps/server/src/modules/video-conference/controller/video-conference.controller.ts index b55eac17f64..4713980023a 100644 --- a/apps/server/src/modules/video-conference/controller/video-conference.controller.ts +++ b/apps/server/src/modules/video-conference/controller/video-conference.controller.ts @@ -1,4 +1,4 @@ -import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, Get, HttpStatus, Param, Put, Req } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; @@ -15,7 +15,7 @@ import { } from './dto'; @ApiTags('VideoConference') -@Authenticate('jwt') +@JwtAuthentication() @Controller('videoconference2') export class VideoConferenceController { constructor( 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 61f8ee3c6f4..66c84080636 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 @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ICurrentUser } from '@infra/auth-guard'; import { CalendarEventDto, CalendarService } from '@infra/calendar'; -import { ICurrentUser } from '@modules/authentication'; import { AuthorizationService } from '@modules/authorization'; import { CourseService } from '@modules/learnroom'; import { LegacySchoolService } from '@modules/legacy-school'; 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 f280714ab28..c9a162036b9 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 @@ -1,7 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ICurrentUser } from '@infra/auth-guard'; import { CalendarService } from '@infra/calendar'; import { CalendarEventDto } from '@infra/calendar/dto/calendar-event.dto'; -import { ICurrentUser } from '@modules/authentication'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { CourseService } from '@modules/learnroom'; import { LegacySchoolService } from '@modules/legacy-school'; diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts index c6d10e58d68..b41ec6729b6 100644 --- a/apps/server/src/shared/common/guards/type.guard.spec.ts +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -215,6 +215,95 @@ describe('TypeGuard', () => { }); }); + describe('isStringOfValues', () => { + describe('when value is in values', () => { + it('should return true', () => { + const value = 'string'; + + expect(TypeGuard.isStringOfStrings(value, [value])).toBe(true); + }); + + it('should return true', () => { + expect(TypeGuard.isStringOfStrings('string', ['string', ''])).toBe(true); + }); + }); + + describe('when value is NOT in values', () => { + it('should be return false', () => { + expect(TypeGuard.isStringOfStrings(undefined, ['string'])).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isStringOfStrings(null, ['string'])).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isStringOfStrings({}, ['string'])).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isStringOfStrings(1, ['string'])).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isStringOfStrings('string', [''])).toBe(false); + }); + }); + }); + + describe('checkStringOfValues', () => { + describe('when value is in values', () => { + it('should return string', () => { + const value = 'string'; + + const result = TypeGuard.checkStringOfStrings(value, [value]); + + expect(result).toBe(value); + }); + + it('should return string', () => { + const value = 'string'; + + const result = TypeGuard.checkStringOfStrings(value, [value, ' ']); + + expect(result).toBe(value); + }); + }); + + describe('when value is NOT in values', () => { + const buildError = () => new Error('Value is not in strings'); + + it('should throw an error', () => { + const error = buildError(); + + expect(() => TypeGuard.checkStringOfStrings(undefined, ['string'])).toThrowError(error); + }); + + it('should throw an error', () => { + const error = buildError(); + + expect(() => TypeGuard.checkStringOfStrings(null, ['string'])).toThrowError(error); + }); + + it('should throw an error', () => { + const error = buildError(); + + expect(() => TypeGuard.checkStringOfStrings({}, ['string'])).toThrowError(error); + }); + + it('should throw an error', () => { + const error = buildError(); + expect(() => TypeGuard.checkStringOfStrings(1, ['string'])).toThrowError(error); + }); + + it('should throw an error', () => { + const error = buildError(); + + expect(() => TypeGuard.checkStringOfStrings('string', [''])).toThrowError(error); + }); + }); + }); + describe('isArray', () => { describe('when passing type of value is an array', () => { it('should be return true', () => { diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index 48c0388dbd3..ce4a35e8aa4 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -54,6 +54,20 @@ export class TypeGuard { return value; } + static isStringOfStrings(value: unknown, values: T[]): value is T { + const isStringOfValue = TypeGuard.isString(value) && values.includes(value as T); + + return isStringOfValue; + } + + static checkStringOfStrings(value: unknown, values: T[]): T { + if (!TypeGuard.isStringOfStrings(value, values)) { + throw new Error('Value is not in strings'); + } + + return value; + } + static isArray(value: unknown): value is [] { const isArray = Array.isArray(value); diff --git a/apps/server/src/shared/common/interceptor/request-logging.interceptor.ts b/apps/server/src/shared/common/interceptor/request-logging.interceptor.ts index 4e3c256733f..b1fab899762 100644 --- a/apps/server/src/shared/common/interceptor/request-logging.interceptor.ts +++ b/apps/server/src/shared/common/interceptor/request-logging.interceptor.ts @@ -1,4 +1,4 @@ -import { ICurrentUser } from '@modules/authentication/interface/user'; +import { ICurrentUser } from '@infra/auth-guard'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { LegacyLogger, RequestLoggingBody } from '@src/core/logger'; import { Request } from 'express'; diff --git a/apps/server/src/shared/domain/entity/import-user.entity.ts b/apps/server/src/shared/domain/entity/import-user.entity.ts index 24a268ccb5e..703b7f1bc0b 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.ts +++ b/apps/server/src/shared/domain/entity/import-user.entity.ts @@ -23,6 +23,7 @@ export interface ImportUserProperties { user?: User; matchedBy?: MatchCreator; flagged?: boolean; + externalRoleNames?: string[]; } export enum MatchCreator { @@ -48,6 +49,8 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc if (Array.isArray(props.classNames) && props.classNames.length > 0) this.classNames.push(...props.classNames); if (props.user && props.matchedBy) this.setMatch(props.user, props.matchedBy); if (props.flagged && props.flagged === true) this.flagged = true; + if (Array.isArray(props.externalRoleNames) && props.externalRoleNames.length > 0) + this.externalRoleNames = props.externalRoleNames; } @ManyToOne(() => SchoolEntity, { fieldName: 'schoolId', wrappedReference: true, eager: true }) @@ -111,6 +114,9 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc @Property({ type: Boolean }) flagged = false; + @Property({ nullable: true }) + externalRoleNames?: string[]; + setMatch(user: User, matchedBy: MatchCreator) { if (this.school.id !== user.school.id) { throw new Error('not same school'); diff --git a/apps/server/src/shared/domain/types/school-feature.enum.ts b/apps/server/src/shared/domain/types/school-feature.enum.ts index 6e5391cbc06..da6f3cdf8fd 100644 --- a/apps/server/src/shared/domain/types/school-feature.enum.ts +++ b/apps/server/src/shared/domain/types/school-feature.enum.ts @@ -8,4 +8,5 @@ export enum SchoolFeature { OAUTH_PROVISIONING_ENABLED = 'oauthProvisioningEnabled', SHOW_OUTDATED_USERS = 'showOutdatedUsers', ENABLE_LDAP_SYNC_DURING_MIGRATION = 'enableLdapSyncDuringMigration', + AI_TUTOR = 'aiTutor', } diff --git a/apps/server/src/shared/repo/mongo.patterns.ts b/apps/server/src/shared/repo/mongo.patterns.ts index 572fc9819bc..0cee8dbcf3b 100644 --- a/apps/server/src/shared/repo/mongo.patterns.ts +++ b/apps/server/src/shared/repo/mongo.patterns.ts @@ -4,5 +4,5 @@ export class MongoPatterns { * Used to remove all non-language characters except numbers, whitespace or minus. */ static REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST = - /[^\-_\w\d áàâäãåçéèêëíìîïñóòôöõúùûüýÿæœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ]/gi; + /[^\-_\w\d áàâäãåçéèêëíìîïñóòôöõúùûüýÿæœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒß]/gi; } diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts index 7502ecfdce5..7cc8596c6e5 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts @@ -299,7 +299,7 @@ describe(SchoolExternalToolRepo.name, () => { it('should delete a SchoolExternalTool', async () => { const { schoolExternalTool1 } = await setup(); - repo.deleteById(schoolExternalTool1.id); + await repo.deleteById(schoolExternalTool1.id); const result: SchoolExternalTool[] = await repo.find({ schoolId: schoolExternalTool1.school.id }); 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 42aa69db1ca..f88dfd3ec1e 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 @@ -48,8 +48,8 @@ export class SchoolExternalToolRepo { return domainObject; } - public deleteById(id: EntityId): void { - this.em.remove(this.em.getReference(this.entityName, id)); + public async deleteById(id: EntityId): Promise { + return this.em.removeAndFlush(this.em.getReference(this.entityName, id)); } async findByExternalToolId(toolId: string): Promise { diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index d499efc45e8..4dba77016ac 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -371,6 +371,31 @@ describe('user repo', () => { // id do not exist await expect(repo.findForImportUser(school)).rejects.toThrowError(); }); + + describe('when the first or lastname of the user contains "ß"', () => { + describe('when the name filter query is exactly the first or lastname of the user', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const user = userFactory.build({ school, firstName: 'Martin', lastName: 'Beißner' }); + await em.persistAndFlush([user]); + em.clear(); + + return { + school, + user, + }; + }; + + it('should return the searched user', async () => { + const { school, user } = await setup(); + + const [result, count] = await repo.findForImportUser(school, { name: user.lastName }); + + expect(count).toEqual(1); + expect(result.map((u) => u.id)).toContain(user.id); + }); + }); + }); }); describe('findByEmail', () => { diff --git a/apps/server/src/shared/repo/user/user.scope.spec.ts b/apps/server/src/shared/repo/user/user.scope.spec.ts index 484c5958d6d..6957c9acb5c 100644 --- a/apps/server/src/shared/repo/user/user.scope.spec.ts +++ b/apps/server/src/shared/repo/user/user.scope.spec.ts @@ -158,6 +158,26 @@ describe('UserScope', () => { expect(scope.query).toEqual({}); }); }); + + describe('when a name contains "ß"', () => { + const setup = () => { + const name = 'Beißner'; + + return { + name, + }; + }; + + it('should return scope with added query where first or lastname is given without removing the "ß"', () => { + const { name } = setup(); + + scope.byName(name); + + expect(scope.query).toEqual({ + $or: [{ firstName: new RegExp(name, 'i') }, { lastName: new RegExp(name, 'i') }], + }); + }); + }); }); describe('withDeleted', () => { @@ -173,7 +193,7 @@ describe('UserScope', () => { it('should add a query that removes deleted users', () => { scope.withDeleted(false); - expect(scope.query).toEqual({ deletedAt: { $exists: false } }); + expect(scope.query).toEqual({ $or: [{ deletedAt: { $exists: false } }, { deletedAt: null }] }); }); }); }); diff --git a/apps/server/src/shared/repo/user/user.scope.ts b/apps/server/src/shared/repo/user/user.scope.ts index 47efd5afe71..884fe970a90 100644 --- a/apps/server/src/shared/repo/user/user.scope.ts +++ b/apps/server/src/shared/repo/user/user.scope.ts @@ -51,7 +51,7 @@ export class UserScope extends Scope { withDeleted(deleted?: boolean): UserScope { if (!deleted) { - this.addQuery({ deletedAt: { $exists: false } }); + this.addQuery({ $or: [{ deletedAt: { $exists: false } }, { deletedAt: null }] }); } return this; } diff --git a/apps/server/src/modules/authentication/testing/currentuser.factory.ts b/apps/server/src/shared/testing/factory/currentuser.factory.ts similarity index 88% rename from apps/server/src/modules/authentication/testing/currentuser.factory.ts rename to apps/server/src/shared/testing/factory/currentuser.factory.ts index df20326b942..fe56aa69080 100644 --- a/apps/server/src/modules/authentication/testing/currentuser.factory.ts +++ b/apps/server/src/shared/testing/factory/currentuser.factory.ts @@ -1,6 +1,6 @@ -import { BaseFactory } from '@shared/testing'; +import { ICurrentUser } from '@infra/auth-guard'; import { ObjectId } from 'bson'; -import { ICurrentUser } from '../interface'; +import { BaseFactory } from './base.factory'; class CurrentUser implements ICurrentUser { userId: string; diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 3c3612072aa..72234a94ddb 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -1,24 +1,32 @@ +export * from './axios-error.factory'; export * from './axios-response.factory'; export * from './base.factory'; export * from './board.factory'; export * from './boardelement.factory'; export * from './column-board-node.factory'; +export { countyEmbeddableFactory } from './county.embeddable.factory'; export * from './course.factory'; export * from './coursegroup.factory'; +export * from './currentuser.factory'; export * from './domainobject'; export * from './external-group-dto.factory'; +export { externalSchoolDtoFactory } from './external-school-dto.factory'; export * from './external-tool-pseudonym.factory'; export * from './federal-state.factory'; export * from './filerecord.factory'; export * from './group-entity.factory'; export * from './h5p-content.factory'; export * from './import-user.factory'; +export * from './jwt.test.factory'; +export * from './jwtpayload.factory'; +export * from './legacy-file-entity-mock.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-entity.factory'; +export { schoolSystemOptionsEntityFactory } from './school-system-options-entity.factory'; export * from './schoolyear.factory'; export * from './share-token.do.factory'; export * from './storageprovider.factory'; @@ -27,14 +35,8 @@ export * from './systemEntityFactory'; export * from './task.factory'; export * from './team.factory'; export * from './teamuser.factory'; +export * from './tldraw-file-dto.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 './legacy-file-entity-mock.factory'; -export * from './jwt.test.factory'; -export * from './axios-error.factory'; -export * from './tldraw-file-dto.factory'; -export { externalSchoolDtoFactory } from './external-school-dto.factory'; -export { schoolSystemOptionsEntityFactory } from './school-system-options-entity.factory'; -export { countyEmbeddableFactory } from './county.embeddable.factory'; diff --git a/apps/server/src/modules/authentication/testing/jwtpayload.factory.ts b/apps/server/src/shared/testing/factory/jwtpayload.factory.ts similarity index 89% rename from apps/server/src/modules/authentication/testing/jwtpayload.factory.ts rename to apps/server/src/shared/testing/factory/jwtpayload.factory.ts index cbc61f8cf9e..bd5c0582182 100644 --- a/apps/server/src/modules/authentication/testing/jwtpayload.factory.ts +++ b/apps/server/src/shared/testing/factory/jwtpayload.factory.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; -import { BaseFactory } from '@shared/testing'; +import { JwtPayload } from '@infra/auth-guard'; import { ObjectId } from 'bson'; -import { JwtPayload } from '../interface/jwt-payload'; +import { BaseFactory } from './base.factory'; class JwtPayloadImpl implements JwtPayload { accountId: string; diff --git a/apps/server/src/shared/testing/map-user-to-current-user.ts b/apps/server/src/shared/testing/map-user-to-current-user.ts index 969b8181aa4..89c739f4a22 100644 --- a/apps/server/src/shared/testing/map-user-to-current-user.ts +++ b/apps/server/src/shared/testing/map-user-to-current-user.ts @@ -1,5 +1,5 @@ +import { ICurrentUser } from '@infra/auth-guard'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; import { User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; diff --git a/apps/server/src/shared/testing/test-api-client.spec.ts b/apps/server/src/shared/testing/test-api-client.spec.ts index 06a53198db5..e69bc683894 100644 --- a/apps/server/src/shared/testing/test-api-client.spec.ts +++ b/apps/server/src/shared/testing/test-api-client.spec.ts @@ -12,9 +12,9 @@ import { Put, UnauthorizedException, } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; import { Test } from '@nestjs/testing'; +import { ApiKeyGuard } from '@infra/auth-guard'; import { accountFactory } from '@src/modules/account/testing'; import { TestApiClient } from './test-api-client'; @@ -262,7 +262,7 @@ describe(TestApiClient.name, () => { const moduleFixture = await Test.createTestingModule({ controllers: [TestXApiKeyController], }) - .overrideGuard(AuthGuard('api-key')) + .overrideGuard(ApiKeyGuard) .useValue({ canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); diff --git a/backup/setup/boardnodes.json b/backup/setup/boardnodes.json new file mode 100644 index 00000000000..af2c1dea0f7 --- /dev/null +++ b/backup/setup/boardnodes.json @@ -0,0 +1,74 @@ +[ + { + "_id": { + "$oid": "649452a81fef6fd796117f7f" + }, + "context": { + "$oid": "5fa3a2f3a9c31a26f4d1d309" + }, + "contextType": "course", + "createdAt": { + "$date": "2023-06-22T13:54:48.996Z" + }, + "isVisible": true, + "layout": "columns", + "level": 0, + "path": ",", + "position": 0, + "title": "CY Kurs-Board", + "type": "column-board", + "updatedAt": { + "$date": "2023-06-22T13:54:48.996Z" + } + }, + { + "_id": { + "$oid": "649452a91fef6fd796117f80" + }, + "createdAt": { + "$date": "2024-05-31T09:01:06.497Z" + }, + "level": 1, + "path": ",649452a81fef6fd796117f7f,", + "position": 0, + "title": "CY Column", + "type": "column", + "updatedAt": { + "$date": "2024-05-31T09:01:06.508Z" + } + }, + { + "_id": { + "$oid": "649452a91fef6fd796117f81" + }, + "createdAt": { + "$date": "2024-05-31T09:01:06.500Z" + }, + "level": 2, + "path": ",649452a81fef6fd796117f7f,649452a91fef6fd796117f80,", + "position": 0, + "title": "CY Card", + "type": "card", + "updatedAt": { + "$date": "2024-05-31T09:01:06.508Z" + }, + "height": 254.203125 + }, + { + "_id": { + "$oid": "649457451fef6fd796117f8a" + }, + "createdAt": { + "$date": "2023-06-22T14:14:29.082Z" + }, + "level": 3, + "path": ",649452a81fef6fd796117f7f,649452a91fef6fd796117f80,649452a91fef6fd796117f81,", + "position": 0, + "type": "deleted-element", + "updatedAt": { + "$date": "2023-06-22T14:14:29.083Z" + }, + "title": "CY Deleted Tool", + "deletedElementType": "externalTool" + } +] \ No newline at end of file diff --git a/config/default.schema.json b/config/default.schema.json index c3cd6b52b43..7b28b3ff4fc 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -708,6 +708,11 @@ "type": "string", "description": "The Schulcloud domain." }, + "TRAINING_URL": { + "type": "string", + "default": "https://lernen.dbildungscloud.de", + "description": "URL for the platform training material" + }, "FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED": { "type": "boolean", "default": true, @@ -1123,7 +1128,7 @@ }, "ACCESSIBILITY_REPORT_EMAIL": { "type": "string", - "default": "lernen.cloud@dataport.de", + "default": "dbildungscloud@dataport.de", "description": "Email to report accessibility issue" }, "FEATURE_COLUMN_BOARD_ENABLED": { @@ -1650,6 +1655,16 @@ "type": "string", "default": "ws://localhost:4450", "description": "URL for connecting to the WebSocketServer" + }, + "FEATURE_AI_TUTOR_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the AI Tutor" + }, + "FEATURE_ROOMS_ENABLED": { + "type": "boolean", + "default": "false", + "description": "Enables the rooms feature" } }, "required": [] diff --git a/config/development.json b/config/development.json index 5cf48962fa7..d51a82784be 100644 --- a/config/development.json +++ b/config/development.json @@ -89,5 +89,7 @@ "BOARD_COLLABORATION_URI": "ws://localhost:4450", "ADMIN_API": { "ALLOWED_API_KEYS": "thisisasupersecureapikeythatisabsolutelysave" - } + }, + "TRAINING_URL": "https://lernen.dbildungscloud.de", + "FEATURE_ROOMS_ENABLED": true } diff --git a/jest.config.ts b/jest.config.ts index 4f30624cba0..fd916706db8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -44,7 +44,7 @@ if (!process.env.RUN_WITHOUT_JEST_COVERAGE) { }, // add custom paths: './apps/server/path...': { branches: X, functions: ... } }, - testTimeout: 5000 + testTimeout: 5000, }; } diff --git a/package-lock.json b/package-lock.json index dc975fd2d23..f01712cf875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@mikro-orm/migrations-mongodb": "^5.6.16", "@mikro-orm/mongodb": "^5.6.16", "@mikro-orm/nestjs": "^5.2.1", - "@nestjs/axios": "^3.0.0", + "@nestjs/axios": "^3.0.3", "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.2.4", "@nestjs/config": "^3.0.1", @@ -42,6 +42,7 @@ "@nestjs/swagger": "^7.1.10", "@nestjs/websockets": "^10.3.7", "@socket.io/mongo-adapter": "^0.3.2", + "@socket.io/redis-adapter": "^8.3.0", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", "@types/pdfmake": "^0.2.8", @@ -55,9 +56,8 @@ "async": "^3.2.2", "async-mutex": "^0.4.0", "aws-sdk": "^2.1659.0", - "axios": "^1.7.2", + "axios": "^1.7.4", "axios-mock-adapter": "^1.21.2", - "bbb-promise": "^1.2.0", "bcryptjs": "*", "body-parser": "^1.15.2", "bson": "^4.6.0", @@ -89,7 +89,7 @@ "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", "ioredis": "^5.3.2", - "jose": "^5.6.3", + "jose": "^1.28.1", "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^2.0.5", @@ -144,7 +144,9 @@ "uuid": "^8.3.0", "winston": "^3.8.2", "ws": "^8.17.1", + "xml2js": "^0.6.2", "y-protocols": "^1.0.6", + "yaml": "^2.5.0", "yjs": "^13.6.11" }, "devDependencies": { @@ -153,10 +155,10 @@ "@feathersjs/adapter-tests": "^5.0.29", "@golevelup/ts-jest": "^0.5.0", "@jest-mock/express": "^1.4.5", - "@nestjs/cli": "^10.1.17", + "@nestjs/cli": "^10.4.2", "@nestjs/schematics": "^10.0.2", "@nestjs/testing": "^10.2.4", - "@openapitools/openapi-generator-cli": "^2.13.4", + "@openapitools/openapi-generator-cli": "^2.13.5", "@types/adm-zip": "^0.5.0", "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", @@ -187,17 +189,17 @@ "copyfiles": "^2.4.0", "esbuild": "^0.17.10", "esbuild-plugin-d.ts": "^1.3.0", - "eslint": "^8.30.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-config-prettier": "^8.5.0", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jest": "^27.1.6", - "eslint-plugin-jsx-a11y": "^6.6.1", - "eslint-plugin-no-only-tests": "^3.1.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.10.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-jsx-a11y": "^6.9.0", + "eslint-plugin-no-only-tests": "^3.3.0", "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-promise": "^7.1.0", "fishery": "^2.2.2", "jest": "^29.2.2", "jwt-decode": "^3.1.2", @@ -223,7 +225,7 @@ "typescript": "^5.5.3" }, "engines": { - "node": "18", + "node": "20", "npm": ">=9" } }, @@ -239,18 +241,21 @@ } }, "node_modules/@angular-devkit/core": { - "version": "16.2.0", + "version": "17.3.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.8.tgz", + "integrity": "sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q==", "dev": true, "license": "MIT", "dependencies": { "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.0", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", "rxjs": "7.8.1", "source-map": "0.7.4" }, "engines": { - "node": "^16.14.0 || >=18.10.0", + "node": "^18.13.0 || >=20.9.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, @@ -263,8 +268,30 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/core/node_modules/picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@angular-devkit/core/node_modules/source-map": { "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -272,31 +299,35 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "16.2.0", + "version": "17.3.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.8.tgz", + "integrity": "sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "16.2.0", - "jsonc-parser": "3.2.0", - "magic-string": "0.30.1", + "@angular-devkit/core": "17.3.8", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.8", "ora": "5.4.1", "rxjs": "7.8.1" }, "engines": { - "node": "^16.14.0 || >=18.10.0", + "node": "^18.13.0 || >=20.9.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "16.2.0", + "version": "17.3.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-17.3.8.tgz", + "integrity": "sha512-TjmiwWJarX7oqvNiRAroQ5/LeKUatxBOCNEuKXO/PV8e7pn/Hr/BqfFm+UcYrQoFdZplmtNAfqmbqgVziKvCpA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "16.2.0", - "@angular-devkit/schematics": "16.2.0", + "@angular-devkit/core": "17.3.8", + "@angular-devkit/schematics": "17.3.8", "ansi-colors": "4.1.3", - "inquirer": "8.2.4", + "inquirer": "9.2.15", "symbol-observable": "4.0.0", "yargs-parser": "21.1.1" }, @@ -304,13 +335,15 @@ "schematics": "bin/schematics.js" }, "engines": { - "node": "^16.14.0 || >=18.10.0", + "node": "^18.13.0 || >=20.9.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/schematics-cli/node_modules/ansi-colors": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, "license": "MIT", "engines": { @@ -319,6 +352,8 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -331,23 +366,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { - "version": "4.1.2", + "node_modules/@angular-devkit/schematics-cli/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 12" } }, "node_modules/@angular-devkit/schematics-cli/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -359,40 +391,68 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { - "version": "8.2.4", + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", + "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", + "@ljharb/through": "^2.3.12", + "ansi-escapes": "^4.3.2", + "chalk": "^5.3.0", "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", + "cli-width": "^4.1.0", + "external-editor": "^3.1.0", + "figures": "^3.2.0", "lodash": "^4.17.21", - "mute-stream": "0.0.8", + "mute-stream": "1.0.0", "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^7.0.0" + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=18" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/supports-color": { - "version": "7.2.0", + "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=8" @@ -400,12 +460,21 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/yargs-parser": { "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { "node": ">=12" } }, + "node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true, + "license": "MIT" + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.0.9", "license": "MIT", @@ -3277,18 +3346,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.17.0", - "dev": true, - "license": "MIT", - "dependencies": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.22.15", "dev": true, @@ -3731,14 +3788,42 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "1.4.0", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -3755,6 +3840,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -3770,11 +3857,15 @@ }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.19.0", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3789,6 +3880,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -3800,11 +3893,15 @@ }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -3814,6 +3911,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@faker-js/faker": { "version": "8.1.0", "dev": true, @@ -3977,26 +4084,6 @@ } } }, - "node_modules/@feathersjs/authentication/node_modules/jsonwebtoken": { - "version": "9.0.2", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, "node_modules/@feathersjs/authentication/node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", @@ -4434,18 +4521,29 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.8", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "dev": true, @@ -4467,6 +4565,102 @@ "version": "1.2.0", "license": "MIT" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "dev": true, @@ -5277,13 +5471,15 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -5298,7 +5494,9 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "license": "MIT", "engines": { @@ -5306,12 +5504,14 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.5", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -5320,12 +5520,14 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.17", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@jsdevtools/ono": { @@ -5352,6 +5554,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@ljharb/through": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.13.tgz", + "integrity": "sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "license": "MIT", @@ -5888,9 +6103,10 @@ } }, "node_modules/@nestjs/axios": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.2.tgz", - "integrity": "sha512-Z6GuOUdNQjP7FX+OuV2Ybyamse+/e0BFdTWBX5JxpBDKA+YkdLynDgG6HTF04zy6e9zPa19UX0WA2VDoehwhXQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.3.tgz", + "integrity": "sha512-h6TCn3yJwD6OKqqqfmtRS5Zo4E46Ip2n+gK1sqwzNBC+qxQ9xpCu+ODVRFur6V3alHSCSBxb3nNtt73VEdluyA==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "axios": "^1.3.1", @@ -5909,41 +6125,40 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.1.17", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", + "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "16.2.0", - "@angular-devkit/schematics": "16.2.0", - "@angular-devkit/schematics-cli": "16.2.0", + "@angular-devkit/core": "17.3.8", + "@angular-devkit/schematics": "17.3.8", + "@angular-devkit/schematics-cli": "17.3.8", "@nestjs/schematics": "^10.0.1", "chalk": "4.1.2", - "chokidar": "3.5.3", - "cli-table3": "0.6.3", + "chokidar": "3.6.0", + "cli-table3": "0.6.5", "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "8.0.0", + "fork-ts-checker-webpack-plugin": "9.0.2", + "glob": "10.4.2", "inquirer": "8.2.6", "node-emoji": "1.11.0", "ora": "5.4.1", - "os-name": "4.0.1", - "rimraf": "4.4.1", - "shelljs": "0.8.5", - "source-map-support": "0.5.21", "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", - "typescript": "5.1.6", - "webpack": "5.88.2", + "typescript": "5.3.3", + "webpack": "5.92.1", "webpack-node-externals": "3.0.0" }, "bin": { "nest": "bin/nest.js" }, "engines": { - "node": ">= 16" + "node": ">= 16.14" }, "peerDependencies": { - "@swc/cli": "^0.1.62", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -5971,6 +6186,8 @@ }, "node_modules/@nestjs/cli/node_modules/brace-expansion": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "license": "MIT", "dependencies": { @@ -5992,6 +6209,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@nestjs/cli/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@nestjs/cli/node_modules/color-convert": { "version": "2.0.1", "dev": true, @@ -6016,18 +6258,42 @@ "node": ">= 6" } }, - "node_modules/@nestjs/cli/node_modules/glob": { - "version": "9.3.5", + "node_modules/@nestjs/cli/node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nestjs/cli/node_modules/glob": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", + "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6059,7 +6325,9 @@ } }, "node_modules/@nestjs/cli/node_modules/minimatch": { - "version": "8.0.4", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { @@ -6072,24 +6340,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@nestjs/cli/node_modules/minipass": { - "version": "4.2.8", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/@nestjs/cli/node_modules/rimraf": { - "version": "4.4.1", + "node_modules/@nestjs/cli/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", - "dependencies": { - "glob": "^9.2.0" - }, - "bin": { - "rimraf": "dist/cjs/src/bin.js" - }, "engines": { "node": ">=14" }, @@ -6109,7 +6365,9 @@ } }, "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.1.6", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6699,17 +6957,18 @@ } }, "node_modules/@openapitools/openapi-generator-cli": { - "version": "2.13.4", - "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.13.4.tgz", - "integrity": "sha512-4JKyrk55ohQK2FcuZbPdNvxdyXD14jjOIvE8hYjJ+E1cHbRbfXQXbYnjTODFE52Gx8eAxz8C9icuhDYDLn7nww==", + "version": "2.13.5", + "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.13.5.tgz", + "integrity": "sha512-9VgeKOTiiatKSwZDKKB3C86cW8tN9eDcFohotD4eisdK38UQswk/4Ysoq9KChRCbymjoMp6AIDHPtK1DQ2fTgw==", "dev": true, "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "@nestjs/axios": "3.0.2", "@nestjs/common": "10.3.0", "@nestjs/core": "10.3.0", "@nuxtjs/opencollective": "0.3.2", - "axios": "1.6.8", + "axios": "1.7.4", "chalk": "4.1.2", "commander": "8.3.0", "compare-versions": "4.1.4", @@ -6735,6 +6994,18 @@ "url": "https://opencollective.com/openapi_generator" } }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/@nestjs/axios": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.2.tgz", + "integrity": "sha512-Z6GuOUdNQjP7FX+OuV2Ybyamse+/e0BFdTWBX5JxpBDKA+YkdLynDgG6HTF04zy6e9zPa19UX0WA2VDoehwhXQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@openapitools/openapi-generator-cli/node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -6762,17 +7033,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@openapitools/openapi-generator-cli/node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/@openapitools/openapi-generator-cli/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6925,23 +7185,15 @@ "node": ">=10.13.0" } }, - "node_modules/@pkgr/utils": { - "version": "2.3.1", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "is-glob": "^4.0.3", - "open": "^8.4.0", - "picocolors": "^1.0.0", - "tiny-glob": "^0.2.9", - "tslib": "^2.4.0" - }, + "optional": true, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" + "node": ">=14" } }, "node_modules/@redis/bloom": { @@ -8367,6 +8619,22 @@ "socket.io-adapter": "^2.5.2" } }, + "node_modules/@socket.io/redis-adapter": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz", + "integrity": "sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==", + "dependencies": { + "debug": "~4.3.1", + "notepack.io": "~3.0.1", + "uid2": "1.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "socket.io-adapter": "^2.5.4" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "license": "MIT" @@ -8554,7 +8822,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.1", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true, "license": "MIT" }, @@ -8662,6 +8932,8 @@ }, "node_modules/@types/json5": { "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true, "license": "MIT" }, @@ -8709,11 +8981,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/@types/passport": { "version": "1.0.7", "dev": true, @@ -9258,8 +9525,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, "license": "MIT", "dependencies": { @@ -9269,21 +9545,29 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", "dev": true, "license": "MIT", "dependencies": { @@ -9294,22 +9578,28 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", "dev": true, "license": "MIT", "dependencies": { @@ -9318,6 +9608,8 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9326,30 +9618,36 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -9357,22 +9655,26 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -9381,21 +9683,27 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, "license": "Apache-2.0" }, @@ -9731,23 +10039,66 @@ } }, "node_modules/aria-query": { - "version": "4.2.2", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" + "deep-equal": "^2.0.5" + } + }, + "node_modules/aria-query/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" }, "engines": { - "node": ">=6.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aria-query/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9762,14 +10113,17 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.7", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" }, "engines": { @@ -9852,15 +10206,18 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -9963,9 +10320,11 @@ } }, "node_modules/ast-types-flow": { - "version": "0.0.7", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, "node_modules/astral-regex": { "version": "2.0.0", @@ -9995,8 +10354,13 @@ "license": "MIT" }, "node_modules/available-typed-arrays": { - "version": "1.0.5", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -10102,18 +10466,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/aws-sdk/node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/aws-sign2": { "version": "0.7.0", "license": "Apache-2.0", @@ -10126,7 +10478,9 @@ "license": "MIT" }, "node_modules/axe-core": { - "version": "4.6.1", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.0.tgz", + "integrity": "sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==", "dev": true, "license": "MPL-2.0", "engines": { @@ -10134,9 +10488,10 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -10155,9 +10510,54 @@ } }, "node_modules/axobject-query": { - "version": "2.2.0", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", + "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/axobject-query/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axobject-query/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" }, "node_modules/b4a": { "version": "1.6.6", @@ -10247,17 +10647,6 @@ "node": "^4.5.0 || >= 5.9" } }, - "node_modules/bbb-promise": { - "version": "1.2.0", - "license": "ISC", - "dependencies": { - "build-url": "^1.0.9", - "request": "^2.81.0", - "request-promise": "^4.2.0", - "sha1": "^1.1.1", - "xml2js-es6-promise": "^1.1.1" - } - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "license": "BSD-3-Clause", @@ -10454,25 +10843,36 @@ } }, "node_modules/browserslist": { - "version": "4.19.1", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001286", - "electron-to-chromium": "^1.4.17", - "escalade": "^3.1.1", - "node-releases": "^2.0.1", - "picocolors": "^1.0.0" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" }, "engines": { "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" } }, "node_modules/bs-logger": { @@ -10563,10 +10963,6 @@ "version": "1.0.0", "license": "MIT" }, - "node_modules/build-url": { - "version": "1.3.3", - "license": "MIT" - }, "node_modules/bunyan": { "version": "1.8.15", "engines": [ @@ -10695,13 +11091,25 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001309", + "version": "1.0.30001649", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001649.tgz", + "integrity": "sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ==", "dev": true, - "license": "CC-BY-4.0", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, "node_modules/caseless": { "version": "0.12.0", @@ -10762,7 +11170,9 @@ } }, "node_modules/chalk": { - "version": "5.0.0", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -10784,13 +11194,6 @@ "dev": true, "license": "MIT" }, - "node_modules/charenc": { - "version": "0.0.2", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/check-error": { "version": "1.0.2", "dev": true, @@ -10928,7 +11331,9 @@ } }, "node_modules/cli-table3": { - "version": "0.6.3", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11430,16 +11835,6 @@ "node": ">=10" } }, - "node_modules/core-js-pure": { - "version": "3.21.0", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "license": "MIT" @@ -11456,18 +11851,50 @@ } }, "node_modules/cosmiconfig": { - "version": "7.0.1", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/create-require": { @@ -11516,13 +11943,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/crypto-js": { "version": "4.2.0", "license": "MIT" @@ -11637,6 +12057,57 @@ "node": ">=18" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/date-fns": { "version": "2.28.0", "license": "MIT", @@ -11754,14 +12225,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/define-properties": { "version": "1.2.1", "license": "MIT", @@ -12062,6 +12525,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/easy-table": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.0.tgz", @@ -12107,7 +12577,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.66", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz", + "integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==", "dev": true, "license": "ISC" }, @@ -12140,8 +12612,9 @@ }, "node_modules/end-of-stream": { "version": "1.4.4", - "devOptional": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "once": "^1.4.0" } @@ -12196,7 +12669,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "license": "MIT", "dependencies": { @@ -12234,48 +12709,57 @@ } }, "node_modules/es-abstract": { - "version": "1.22.3", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -12303,18 +12787,86 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", + "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.3.0", "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -12546,7 +13098,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "license": "MIT", "engines": { "node": ">=6" @@ -12636,48 +13190,49 @@ } }, "node_modules/eslint": { - "version": "8.30.0", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint/eslintrc": "^1.4.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -12692,6 +13247,8 @@ }, "node_modules/eslint-config-airbnb-base": { "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, "license": "MIT", "dependencies": { @@ -12717,21 +13274,25 @@ } }, "node_modules/eslint-config-airbnb-typescript": { - "version": "17.0.0", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.1.0.tgz", + "integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==", "dev": true, "license": "MIT", "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.13.0", - "@typescript-eslint/parser": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^5.13.0 || ^6.0.0", + "@typescript-eslint/parser": "^5.0.0 || ^6.0.0", "eslint": "^7.32.0 || ^8.2.0", "eslint-plugin-import": "^2.25.3" } }, "node_modules/eslint-config-prettier": { - "version": "8.5.0", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", "dev": true, "license": "MIT", "bin": { @@ -12760,17 +13321,19 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "3.5.2", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", "dev": true, "license": "ISC", "dependencies": { "debug": "^4.3.4", - "enhanced-resolve": "^5.10.0", - "get-tsconfig": "^4.2.0", - "globby": "^13.1.2", - "is-core-module": "^2.10.0", - "is-glob": "^4.0.3", - "synckit": "^0.8.4" + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -12799,35 +13362,6 @@ } } }, - "node_modules/eslint-import-resolver-typescript/node_modules/globby": { - "version": "13.1.3", - "dev": true, - "license": "MIT", - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.11", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-import-resolver-typescript/node_modules/slash": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-module-utils": { "version": "2.8.0", "dev": true, @@ -12853,7 +13387,9 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.0", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "license": "MIT", "dependencies": { @@ -12873,7 +13409,7 @@ "object.groupby": "^1.0.1", "object.values": "^1.1.7", "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -12903,6 +13439,8 @@ }, "node_modules/eslint-plugin-import/node_modules/json5": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", "dependencies": { @@ -12922,6 +13460,8 @@ }, "node_modules/eslint-plugin-import/node_modules/strip-bom": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", "engines": { @@ -12929,7 +13469,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { - "version": "3.14.2", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "license": "MIT", "dependencies": { @@ -12940,7 +13482,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "27.1.7", + "version": "27.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz", + "integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==", "dev": true, "license": "MIT", "dependencies": { @@ -12950,8 +13494,9 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.0.0", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0", + "eslint": "^7.0.0 || ^8.0.0", + "jest": "*" }, "peerDependenciesMeta": { "@typescript-eslint/eslint-plugin": { @@ -12963,23 +13508,28 @@ } }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.6.1", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz", + "integrity": "sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", + "aria-query": "~5.1.3", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.9.1", + "axobject-query": "~3.1.1", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", + "es-iterator-helpers": "^1.0.19", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", "minimatch": "^3.1.2", - "semver": "^6.3.0" + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.0" }, "engines": { "node": ">=4.0" @@ -12988,16 +13538,10 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-no-only-tests": { - "version": "3.1.0", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", + "integrity": "sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==", "dev": true, "license": "MIT", "engines": { @@ -13006,6 +13550,8 @@ }, "node_modules/eslint-plugin-prettier": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13025,14 +13571,19 @@ } }, "node_modules/eslint-plugin-promise": { - "version": "6.1.1", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.1.0.tgz", + "integrity": "sha512-8trNmPxdAy3W620WKDpaS65NlM5yAumod6XeC4LOb+jxlkG4IVcp68c6dXY2ev+uT4U1PtG57YDV6EGAXN0GbQ==", "dev": true, "license": "ISC", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "node_modules/eslint-scope": { @@ -13081,11 +13632,16 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { @@ -13154,7 +13710,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.1.1", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -13163,6 +13721,9 @@ }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/find-up": { @@ -13205,15 +13766,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/js-sdsl": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/eslint/node_modules/js-yaml": { "version": "4.1.0", "dev": true, @@ -13308,13 +13860,15 @@ "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" }, "node_modules/espree": { - "version": "9.4.1", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.8.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -13324,7 +13878,9 @@ } }, "node_modules/espree/node_modules/acorn": { - "version": "8.8.1", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -13346,7 +13902,9 @@ } }, "node_modules/esquery": { - "version": "1.4.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -13748,7 +14306,9 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" }, "node_modules/fast-glob": { - "version": "3.2.11", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -14108,14 +14668,16 @@ } }, "node_modules/fork-ts-checker-webpack-plugin": { - "version": "8.0.0", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", + "integrity": "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", "chokidar": "^3.5.3", - "cosmiconfig": "^7.0.1", + "cosmiconfig": "^8.2.0", "deepmerge": "^4.2.2", "fs-extra": "^10.0.0", "memfs": "^3.4.1", @@ -14136,6 +14698,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -14150,6 +14714,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -14165,6 +14731,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14176,11 +14744,15 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -14282,7 +14854,9 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.3", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", "dev": true, "license": "Unlicense" }, @@ -14418,11 +14992,14 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -14432,9 +15009,14 @@ } }, "node_modules/get-tsconfig": { - "version": "4.2.0", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.6.tgz", + "integrity": "sha512-ZAqrLlu18NbDdRaHq+AKXzAmqIUPswPWKUchfytdAjiRFnCe5ojG2bstg6mRiZabkKfCoL/e98pbBELIV/YCeA==", "dev": true, "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, "funding": { "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } @@ -14481,6 +15063,8 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, "license": "BSD-2-Clause" }, @@ -14505,11 +15089,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "dev": true, - "license": "MIT" - }, "node_modules/globby": { "version": "11.1.0", "license": "MIT", @@ -14528,11 +15107,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/gm": { "version": "1.25.0", "license": "MIT", @@ -14584,11 +15158,15 @@ } }, "node_modules/graceful-fs": { - "version": "4.2.9", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, @@ -14706,7 +15284,9 @@ } }, "node_modules/has-proto": { - "version": "1.0.1", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -14726,10 +15306,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -14762,7 +15344,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -15192,10 +15776,12 @@ "license": "0BSD" }, "node_modules/internal-slot": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -15307,12 +15893,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15323,6 +15913,22 @@ "dev": true, "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "license": "MIT", @@ -15399,11 +16005,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.0.5", + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15412,18 +16020,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "dev": true, + "node_modules/is-date-object": { + "version": "1.0.5", "license": "MIT", - "bin": { - "is-docker": "cli.js" + "dependencies": { + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-extglob": { @@ -15433,6 +16040,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "license": "MIT", @@ -15490,8 +16110,23 @@ "node": ">=4" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -15563,11 +16198,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15610,10 +16263,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -15637,6 +16292,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "license": "MIT", @@ -15647,23 +16315,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-windows": { - "version": "1.0.2", + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", + "node_modules/is-windows": { + "version": "1.0.2", "dev": true, "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, "node_modules/isarray": { @@ -15819,6 +16493,36 @@ "node": ">=6" } }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -17682,6 +18386,8 @@ }, "node_modules/jest-worker": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", "dependencies": { @@ -17717,9 +18423,14 @@ } }, "node_modules/jose": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", - "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "version": "1.28.2", + "license": "MIT", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, "funding": { "url": "https://github.com/sponsors/panva" } @@ -17997,12 +18708,16 @@ } }, "node_modules/jsx-ast-utils": { - "version": "3.3.3", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, "engines": { "node": ">=4.0" @@ -18185,16 +18900,23 @@ "license": "MIT" }, "node_modules/language-subtag-registry": { - "version": "0.3.21", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, - "license": "ODC-By-1.0" + "license": "CC0-1.0" }, "node_modules/language-tags": { - "version": "1.0.5", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { - "language-subtag-registry": "~0.3.2" + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" } }, "node_modules/ldap-filter": { @@ -18593,19 +19315,10 @@ "version": "2.1.2", "license": "ISC" }, - "node_modules/macos-release": { - "version": "2.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/magic-string": { - "version": "0.30.1", + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18616,7 +19329,9 @@ } }, "node_modules/magic-string/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true, "license": "MIT" }, @@ -18671,11 +19386,13 @@ } }, "node_modules/memfs": { - "version": "3.5.0", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", "dev": true, "license": "Unlicense", "dependencies": { - "fs-monkey": "^1.0.3" + "fs-monkey": "^1.0.4" }, "engines": { "node": ">= 4.0.0" @@ -19744,6 +20461,8 @@ }, "node_modules/node-abort-controller": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true, "license": "MIT" }, @@ -19810,7 +20529,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.1", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true, "license": "MIT" }, @@ -19906,6 +20627,11 @@ "node": ">=0.10.0" } }, + "node_modules/notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" + }, "node_modules/npm-run-path": { "version": "4.0.1", "dev": true, @@ -20152,11 +20878,13 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -20181,13 +20909,16 @@ } }, "node_modules/object.fromentries": { - "version": "2.0.7", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -20265,22 +20996,6 @@ "@jsdevtools/ono": "7.1.3" } }, - "node_modules/open": { - "version": "8.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open-graph-scraper": { "version": "6.2.2", "license": "MIT", @@ -20299,7 +21014,9 @@ "license": "MIT" }, "node_modules/optionator": { - "version": "0.9.1", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -20308,7 +21025,7 @@ "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -20446,21 +21163,6 @@ "node": ">=8" } }, - "node_modules/os-name": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "macos-release": "^2.5.0", - "windows-release": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "dev": true, @@ -20544,6 +21246,13 @@ "node": ">=8" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "0.2.9", "license": "MIT" @@ -20929,6 +21638,15 @@ "servie": "^4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", @@ -21519,8 +22237,9 @@ }, "node_modules/pump": { "version": "3.0.0", - "devOptional": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -21740,17 +22459,42 @@ "version": "0.1.13", "license": "Apache-2.0" }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "license": "MIT" }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -21997,6 +22741,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve.exports": { "version": "1.1.0", "dev": true, @@ -22552,11 +23306,13 @@ } }, "node_modules/safe-array-concat": { - "version": "1.0.1", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -22569,6 +23325,8 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-buffer": { @@ -22595,13 +23353,18 @@ "optional": true }, "node_modules/safe-regex-test": { - "version": "1.0.0", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -22883,17 +23646,6 @@ "version": "1.2.0", "license": "ISC" }, - "node_modules/sha1": { - "version": "1.1.1", - "license": "BSD-3-Clause", - "dependencies": { - "charenc": ">= 0.0.1", - "crypt": ">= 0.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/shallow-copy": { "version": "0.0.1", "license": "MIT" @@ -23479,6 +24231,19 @@ "node": ">=0.10.0" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "license": "MIT", @@ -23573,17 +24338,54 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "license": "MIT" }, + "node_modules/string.prototype.includes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz", + "integrity": "sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { - "version": "1.2.8", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -23593,24 +24395,31 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.7", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.7", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -23626,6 +24435,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "license": "MIT", @@ -23862,6 +24685,8 @@ }, "node_modules/symbol-observable": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", "dev": true, "license": "MIT", "engines": { @@ -23872,21 +24697,6 @@ "version": "3.2.4", "license": "MIT" }, - "node_modules/synckit": { - "version": "0.8.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/utils": "^2.3.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/table": { "version": "6.8.1", "dev": true, @@ -23926,7 +24736,9 @@ } }, "node_modules/terser": { - "version": "5.19.4", + "version": "5.31.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.3.tgz", + "integrity": "sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -23943,15 +24755,17 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.9", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", "schema-utils": "^3.1.1", "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "terser": "^5.26.0" }, "engines": { "node": ">= 10.13.0" @@ -23976,7 +24790,9 @@ } }, "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { - "version": "6.0.1", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -23984,7 +24800,9 @@ } }, "node_modules/terser/node_modules/acorn": { - "version": "8.10.0", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -23996,6 +24814,8 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT" }, @@ -24084,15 +24904,6 @@ "semver": "bin/semver" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "dev": true, - "license": "MIT", - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tiny-inflate": { "version": "1.0.3", "license": "MIT" @@ -24640,25 +25451,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -24668,14 +25484,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -24685,12 +25504,20 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -24742,6 +25569,14 @@ "node": ">= 0.8" } }, + "node_modules/uid2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-1.0.0.tgz", + "integrity": "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/umzug": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.2.1.tgz", @@ -24914,6 +25749,37 @@ "yarn": "*" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "license": "BSD-2-Clause", @@ -25123,7 +25989,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", + "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", "dev": true, "license": "MIT", "dependencies": { @@ -25150,33 +26018,35 @@ } }, "node_modules/webpack": { - "version": "5.88.2", + "version": "5.92.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", + "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.0", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -25212,7 +26082,9 @@ } }, "node_modules/webpack/node_modules/acorn": { - "version": "8.10.0", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -25222,8 +26094,10 @@ "node": ">=0.4.0" } }, - "node_modules/webpack/node_modules/acorn-import-assertions": { - "version": "1.9.0", + "node_modules/webpack/node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -25292,20 +26166,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" - }, - "node_modules/which-typed-array": { - "version": "1.1.13", + "node_modules/which-builtin-type": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", + "dev": true, "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -25314,62 +26193,54 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/windows-release": { - "version": "4.0.0", + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^4.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/windows-release/node_modules/execa": { - "version": "4.1.0", + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/windows-release/node_modules/get-stream": { - "version": "5.2.0", - "dev": true, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "license": "MIT", "dependencies": { - "pump": "^3.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/windows-release/node_modules/human-signals": { - "version": "1.1.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/winston": { @@ -25481,6 +26352,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "license": "MIT", @@ -25573,8 +26499,9 @@ } }, "node_modules/xml2js": { - "version": "0.4.23", - "license": "MIT", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -25583,13 +26510,6 @@ "node": ">=4.0.0" } }, - "node_modules/xml2js-es6-promise": { - "version": "1.1.1", - "license": "ISC", - "dependencies": { - "xml2js": "^0.4.16" - } - }, "node_modules/xmlbuilder": { "version": "11.0.1", "license": "MIT", @@ -25664,11 +26584,15 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "dev": true, + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", + "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 89b1adb95b6..3bc7ad78eb7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "contributors": [], "bugs": {}, "engines": { - "node": "18", + "node": "20", "npm": ">=9" }, "mikro-orm": { @@ -108,11 +108,12 @@ "nest:start:common-cartridge:dev": "nest start common-cartridge --watch --", "nest:start:common-cartridge:debug": "nest start common-cartridge --debug --watch --", "nest:test": "npm run nest:test:cov && npm run nest:lint", - "nest:test:all": "jest", - "nest:test:unit": "jest \"^((?!\\.api\\.spec\\.ts).)*\\.spec\\.ts$\"", + "nest:test:all": "jest \"^((?!(\\.load)\\.spec\\.ts).)*\"", + "nest:test:unit": "jest \"^((?!(\\.api|\\.load)\\.spec\\.ts).)*\\.spec\\.ts$\"", "nest:test:api": "jest \"\\.api\\.spec\\.ts$\"", + "nest:test:load": "jest \"\\.load\\.spec\\.ts$\"", "nest:test:watch": "jest --watch", - "nest:test:cov": "jest --coverage --force-exit --maxWorkers='50%'", + "nest:test:cov": "jest \"^((?!\\.load\\.spec\\.ts).)*\\.spec\\.ts$\" --coverage --force-exit --maxWorkers='50%'", "nest:test:debug": "jest --runInBand", "nest:lint": "eslint apps --ignore-path .gitignore", "nest:lint:fix": "eslint apps --fix --ignore-path .gitignore", @@ -143,7 +144,7 @@ "@mikro-orm/migrations-mongodb": "^5.6.16", "@mikro-orm/mongodb": "^5.6.16", "@mikro-orm/nestjs": "^5.2.1", - "@nestjs/axios": "^3.0.0", + "@nestjs/axios": "^3.0.3", "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.2.4", "@nestjs/config": "^3.0.1", @@ -158,6 +159,7 @@ "@nestjs/swagger": "^7.1.10", "@nestjs/websockets": "^10.3.7", "@socket.io/mongo-adapter": "^0.3.2", + "@socket.io/redis-adapter": "^8.3.0", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", "@types/pdfmake": "^0.2.8", @@ -171,9 +173,8 @@ "async": "^3.2.2", "async-mutex": "^0.4.0", "aws-sdk": "^2.1659.0", - "axios": "^1.7.2", + "axios": "^1.7.4", "axios-mock-adapter": "^1.21.2", - "bbb-promise": "^1.2.0", "bcryptjs": "*", "body-parser": "^1.15.2", "bson": "^4.6.0", @@ -205,7 +206,7 @@ "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", "ioredis": "^5.3.2", - "jose": "^5.6.3", + "jose": "^1.28.1", "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^2.0.5", @@ -260,7 +261,9 @@ "uuid": "^8.3.0", "winston": "^3.8.2", "ws": "^8.17.1", + "xml2js": "^0.6.2", "y-protocols": "^1.0.6", + "yaml": "^2.5.0", "yjs": "^13.6.11" }, "devDependencies": { @@ -269,10 +272,10 @@ "@feathersjs/adapter-tests": "^5.0.29", "@golevelup/ts-jest": "^0.5.0", "@jest-mock/express": "^1.4.5", - "@nestjs/cli": "^10.1.17", + "@nestjs/cli": "^10.4.2", "@nestjs/schematics": "^10.0.2", "@nestjs/testing": "^10.2.4", - "@openapitools/openapi-generator-cli": "^2.13.4", + "@openapitools/openapi-generator-cli": "^2.13.5", "@types/adm-zip": "^0.5.0", "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", @@ -303,17 +306,17 @@ "copyfiles": "^2.4.0", "esbuild": "^0.17.10", "esbuild-plugin-d.ts": "^1.3.0", - "eslint": "^8.30.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-config-prettier": "^8.5.0", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jest": "^27.1.6", - "eslint-plugin-jsx-a11y": "^6.6.1", - "eslint-plugin-no-only-tests": "^3.1.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.10.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-jsx-a11y": "^6.9.0", + "eslint-plugin-no-only-tests": "^3.3.0", "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-promise": "^7.1.0", "fishery": "^2.2.2", "jest": "^29.2.2", "jwt-decode": "^3.1.2", diff --git a/src/components/helper/repo.helper.js b/src/components/helper/repo.helper.js index 949301adc80..c58408468d8 100644 --- a/src/components/helper/repo.helper.js +++ b/src/components/helper/repo.helper.js @@ -10,7 +10,7 @@ const { error } = require('../../logger'); * @param {Integer} upsertedCount; // Number indicating how many documents had to be upserted. Will either be 0 or 1. */ const updateManyResult = ({ acknowledged, matchedCount, modifiedCount }) => { - if (acknowledged) { + if (!acknowledged) { error('mongoose updateMany has failed', { acknowledged, matchedCount, modifiedCount }); } return { success: acknowledged, modifiedDocuments: modifiedCount }; diff --git a/src/services/fileStorage/proxy-service.js b/src/services/fileStorage/proxy-service.js index eaaefbb4071..7d742e20556 100644 --- a/src/services/fileStorage/proxy-service.js +++ b/src/services/fileStorage/proxy-service.js @@ -34,7 +34,6 @@ const { equal: equalIds } = require('../../helper/compare').ObjectId; const { FILE_PREVIEW_SERVICE_URI, FILE_PREVIEW_CALLBACK_URI, - ENABLE_THUMBNAIL_GENERATION, FILE_SECURITY_CHECK_MAX_FILE_SIZE, SECURITY_CHECK_SERVICE_PATH, } = require('../../../config/globals'); @@ -44,40 +43,94 @@ const sanitizeObj = (obj) => { return obj; }; -const prepareThumbnailGeneration = (file, strategy, userId, { name: dataName }, { storageFileName, name: propName }) => - ENABLE_THUMBNAIL_GENERATION - ? Promise.all([ - strategy.getSignedUrl({ - userId, - flatFileName: storageFileName, - localFileName: storageFileName, - download: true, - Expires: 3600 * 24, - }), - strategy.generateSignedUrl({ - userId, - flatFileName: storageFileName.replace(/(\..+)$/, '-thumbnail.png'), - fileType: returnFileType(dataName || propName), // data.type - }), - ]).then(([downloadUrl, signedS3Url]) => - rp - .post({ - url: FILE_PREVIEW_SERVICE_URI, - body: { - downloadUrl, - signedS3Url, - callbackUrl: url.resolve(FILE_PREVIEW_CALLBACK_URI, file.thumbnailRequestToken), - options: { - width: 120, - }, +const getStorageProviderIdAndBucket = async (userId, fileObject, strategy) => { + let storageProviderId = fileObject.storageProviderId; + let bucket = fileObject.bucket; + + if (!storageProviderId) { + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release + const creatorId = + fileObject.creator || + (fileObject.permissions[0]?.refPermModel !== 'user' ? userId : fileObject.permissions[0]?.refId); + + const creator = await userModel.findById(creatorId).exec(); + if (!creator || !creator.schoolId) { + throw new NotFound('User not found'); + } + + const { schoolId } = creator; + + const school = await schoolModel + .findOne({ _id: schoolId }, null, { readPreference: 'primary' }) // primary for afterhook in school.create + .populate('storageProvider') + .select(['storageProvider']) + .lean() + .exec(); + if (school === null) { + throw new NotFound('School not found.'); + } + + storageProviderId = school.storageProvider; + bucket = strategy.getBucket(schoolId); + } + + return { + storageProviderId, + bucket, + }; +}; + +const prepareThumbnailGeneration = async ( + file, + strategy, + userId, + { name: dataName }, + { storageFileName, name: propName } +) => { + if (Configuration.get('ENABLE_THUMBNAIL_GENERATION') === true) { + const fileObject = await FileModel.findOne({ _id: file }).lean().exec(); + + if (!fileObject) { + throw new NotFound('File seems not to be there.'); + } + + const { storageProviderId, bucket } = await getStorageProviderIdAndBucket(userId, fileObject); + + Promise.all([ + strategy.getSignedUrl({ + storageProviderId, + bucket, + flatFileName: storageFileName, + localFileName: storageFileName, + download: true, + Expires: 3600 * 24, + }), + strategy.generateSignedUrl({ + userId, + flatFileName: storageFileName.replace(/(\..+)$/, '-thumbnail.png'), + fileType: returnFileType(dataName || propName), // data.type + }), + ]).then(([downloadUrl, signedS3Url]) => + rp + .post({ + url: FILE_PREVIEW_SERVICE_URI, + body: { + downloadUrl, + signedS3Url, + callbackUrl: url.resolve(FILE_PREVIEW_CALLBACK_URI, file.thumbnailRequestToken), + options: { + width: 120, }, - json: true, - }) - .catch((err) => { - logger.warning(new Error('Can not create tumbnail', err)); // todo err message is lost and throw error - }) - ) - : Promise.resolve(); + }, + json: true, + }) + .catch((err) => { + logger.warning(new Error('Can not create tumbnail', err)); // todo err message is lost and throw error + }) + ); + } + return Promise.resolve(); +}; /** * @@ -86,7 +139,7 @@ const prepareThumbnailGeneration = (file, strategy, userId, { name: dataName }, * @param {FileStorageStrategy} strategy the file storage strategy used * @returns {Promise} Promise that rejects with errors or resolves with no data otherwise */ -const prepareSecurityCheck = (file, userId, strategy) => { +const prepareSecurityCheck = async (file, userId, strategy) => { if (Configuration.get('ENABLE_FILE_SECURITY_CHECK') === true) { if (file.size > FILE_SECURITY_CHECK_MAX_FILE_SIZE) { return FileModel.updateOne( @@ -99,10 +152,12 @@ const prepareSecurityCheck = (file, userId, strategy) => { } ).exec(); } + const { storageProviderId, bucket } = await getStorageProviderIdAndBucket(userId, file); // create a temporary signed URL and provide it to the virus scan service return strategy .getSignedUrl({ - userId, + storageProviderId, + bucket, flatFileName: file.storageFileName, localFileName: file.storageFileName, download: true, @@ -422,10 +477,7 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release - const creatorId = - fileObject.creator || - (fileObject.permissions[0]?.refPermModel !== 'user' ? userId : fileObject.permissions[0]?.refId); + const { storageProviderId, bucket } = await getStorageProviderIdAndBucket(userId, fileObject); if (download && fileObject.securityCheck && fileObject.securityCheck.status === SecurityCheckStatusTypes.BLOCKED) { throw new Forbidden('File access blocked by security check.'); @@ -434,11 +486,11 @@ const signedUrlService = { return canRead(userId, file) .then(() => strategy.getSignedUrl({ - userId: creatorId, + storageProviderId, + bucket, flatFileName: fileObject.storageFileName, localFileName: query.name || fileObject.name, - download, - bucket: fileObject.bucket, + download: true, }) ) .then((res) => ({ @@ -457,16 +509,13 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release - const creatorId = - fileObject.creator || fileObject.permissions[0]?.refPermModel !== 'user' - ? userId - : fileObject.permissions[0]?.refId; + const { storageProviderId, bucket } = await getStorageProviderIdAndBucket(userId, fileObject); return canRead(userId, id) .then(() => strategy.getSignedUrl({ - userId: creatorId, + storageProviderId, + bucket, flatFileName: fileObject.storageFileName, action: 'putObject', }) diff --git a/src/services/fileStorage/strategies/awsS3.js b/src/services/fileStorage/strategies/awsS3.js index c618d549749..d9994cf0faa 100644 --- a/src/services/fileStorage/strategies/awsS3.js +++ b/src/services/fileStorage/strategies/awsS3.js @@ -119,7 +119,7 @@ const listBuckets = async (awsObject) => { const getBucketName = (schoolId) => `${BUCKET_NAME_PREFIX}${schoolId}`; -const createAWSObject = async (schoolId) => { +const createAWSObjectFromSchoolId = async (schoolId) => { const school = await schoolModel .findOne({ _id: schoolId }, null, { readPreference: 'primary' }) // primary for afterhook in school.create .populate('storageProvider') @@ -152,6 +152,33 @@ const createAWSObject = async (schoolId) => { // end legacy }; +const createAWSObjectFromStorageProviderIdAndBucket = async (storageProviderId, bucket) => { + if (Configuration.get('FEATURE_MULTIPLE_S3_PROVIDERS_ENABLED') === true) { + const storageProvider = await StorageProviderModel.findOne({ _id: storageProviderId }).lean().exec(); + + if (!storageProvider) { + throw new NotFound('Storage provider not found.'); + } + + const s3 = getS3(storageProvider); + return { + s3, + bucket, + }; + } + + // begin legacy + if (!awsConfig.endpointUrl) throw new Error('S3 integration is not configured on the server'); + const config = new aws.Config(awsConfig); + config.endpoint = new aws.Endpoint(awsConfig.endpointUrl); + + return { + s3: new aws.S3(config), + bucket, + }; + // end legacy +}; + /** * split files-list in files, that are in current directory, and the sub-directories * @param data is the files-list @@ -305,7 +332,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { throw new BadRequest('No school id parameter given.'); } - const awsObject = await createAWSObject(schoolId); + const awsObject = await createAWSObjectFromSchoolId(schoolId); const data = await createBucket(awsObject); return { message: 'Successfully created s3-bucket!', @@ -358,7 +385,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { return new GeneralError('school not set'); } - return createAWSObject(result.schoolId).then((awsObject) => { + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => { const params = { Bucket: awsObject.bucket, Prefix: path, @@ -384,7 +411,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { return new NotFound('User not found'); } - return createAWSObject(result.schoolId).then((awsObject) => { + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => { // files can be copied to different schools const sourceBucket = `bucket-${externalSchoolId || result.schoolId}`; @@ -413,7 +440,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { if (!result || !result.schoolId) { return new NotFound('User not found'); } - return createAWSObject(result.schoolId).then((awsObject) => { + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => { const params = { Bucket: awsObject.bucket, Delete: { @@ -444,7 +471,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { if (!result || !result.schoolId) { return new NotFound('User not found'); } - return createAWSObject(result.schoolId).then((awsObject) => + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => this.createIfNotExists(awsObject).then((safeAwsObject) => { const params = { Bucket: safeAwsObject.bucket, @@ -462,33 +489,25 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { }); } - getSignedUrl({ userId, flatFileName, localFileName, download, action = 'getObject', bucket = undefined }) { - if (!userId || !flatFileName) { - return Promise.reject(new BadRequest('Missing parameters by getSignedUrl.', { userId, flatFileName })); + getSignedUrl({ storageProviderId, bucket, flatFileName, localFileName, download, action = 'getObject' }) { + if (!storageProviderId || !bucket || !flatFileName) { + return Promise.reject( + new BadRequest('Missing parameters by getSignedUrl.', { storageProviderId, bucket, flatFileName }) + ); } - return UserModel.userModel - .findById(userId) - .lean() - .exec() - .then((result) => { - if (!result || !result.schoolId) { - return new NotFound('User not found'); - } - - return createAWSObject(result.schoolId).then((awsObject) => { - const params = { - Bucket: bucket || awsObject.bucket, - Key: flatFileName, - Expires: Configuration.get('STORAGE_SIGNED_URL_EXPIRE'), - }; - const getBoolean = (value) => value === true || value === 'true'; - if (getBoolean(download)) { - params.ResponseContentDisposition = `attachment; filename = "${localFileName.replace('"', '')}"`; - } - return promisify(awsObject.s3.getSignedUrl.bind(awsObject.s3), awsObject.s3)(action, params); - }); - }); + return createAWSObjectFromStorageProviderIdAndBucket(storageProviderId, bucket).then((awsObject) => { + const params = { + Bucket: bucket, + Key: flatFileName, + Expires: Configuration.get('STORAGE_SIGNED_URL_EXPIRE'), + }; + const getBoolean = (value) => value === true || value === 'true'; + if (getBoolean(download)) { + params.ResponseContentDisposition = `attachment; filename = "${localFileName.replace('"', '')}"`; + } + return promisify(awsObject.s3.getSignedUrl.bind(awsObject.s3), awsObject.s3)(action, params); + }); } /** ** @DEPRECATED *** */ @@ -508,7 +527,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { return new NotFound('User not found'); } - return createAWSObject(result.schoolId).then((awsObject) => { + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => { const fileStream = fs.createReadStream(pathUtil.join(__dirname, '..', 'resources', '.scfake')); const params = { Bucket: awsObject.bucket, @@ -539,7 +558,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { if (!result || !result.schoolId) { return new NotFound('User not found'); } - return createAWSObject(result.schoolId).then((awsObject) => { + return createAWSObjectFromSchoolId(result.schoolId).then((awsObject) => { const params = { Bucket: awsObject.bucket, Prefix: removeLeadingSlash(path),