From 70035f6df501d4ae0463647d8b4f0891f6d02e4f Mon Sep 17 00:00:00 2001 From: Phillip Date: Tue, 19 Dec 2023 14:46:42 +0100 Subject: [PATCH 01/25] BC-5658 update gh-actions remove dof_bingo (#4655) --- .github/workflows/clean.yml | 1 - .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/dependabot-to-jira.yml | 4 ++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/migrations.yml | 4 ++-- .github/workflows/publish_pages.yml | 2 +- .github/workflows/push.yml | 17 +++++++++-------- .github/workflows/tag.yml | 12 +++++++----- .github/workflows/test.yml | 16 ++++++++-------- .github/workflows/test_unstable_e2e.yml | 2 +- 10 files changed, 32 insertions(+), 30 deletions(-) diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index 68a19f09f16..c88633897fd 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -16,4 +16,3 @@ jobs: DEV_KUBE_CONFIG_NBC: ${{ secrets.DEV_KUBE_CONFIG_NBC }} DEV_KUBE_CONFIG_THR: ${{ secrets.DEV_KUBE_CONFIG_THR }} DEV_KUBE_CONFIG_DBC: ${{ secrets.DEV_KUBE_CONFIG_DBC }} - BINGO_REPO_TOKEN: ${{ secrets.BINGO_REPO_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 271bf236767..09d7e22c546 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/dependabot-to-jira.yml b/.github/workflows/dependabot-to-jira.yml index daf6cfc4000..8f750cd33d4 100644 --- a/.github/workflows/dependabot-to-jira.yml +++ b/.github/workflows/dependabot-to-jira.yml @@ -50,9 +50,9 @@ jobs: # one needs a local git repo for k3rnels-actions/pr-update otherwise it will complain about not finding the branches ... - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: update-pull-request - uses: k3rnels-actions/pr-update@v1 + uses: k3rnels-actions/pr-update@v2 id: pr_update with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 3eb2f1e1e58..73aaec3be87 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 'Dependency Review' uses: actions/dependency-review-action@v3 with: diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index b30b52e9f27..0635a6006d8 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -13,13 +13,13 @@ jobs: contents: read timeout-minutes: 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: check all migrations are up in database seed run: test $(grep "\"down\"" ./backup/setup/migrations.json -c) -eq 0 - name: mongodb setup uses: supercharge/mongodb-github-action@1.8.0 - name: setup - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '16' - run: npm ci diff --git a/.github/workflows/publish_pages.yml b/.github/workflows/publish_pages.yml index ad784bb436d..1e8fc1065a9 100644 --- a/.github/workflows/publish_pages.yml +++ b/.github/workflows/publish_pages.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛠 - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Generate documentation 🤖 run: npm run nest:docs:build diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index bf189839193..a7435d1cde9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -29,7 +29,7 @@ jobs: permissions: packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -42,7 +42,7 @@ jobs: - name: Docker meta Service Name id: docker_meta_img - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | @@ -55,22 +55,23 @@ jobs: - name: Set up Docker Buildx if: ${{ env.IMAGE_EXISTS == 0 }} - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push ${{ github.repository }} if: ${{ env.IMAGE_EXISTS == 0 }} - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64 push: true + pull: true tags: ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }} labels: ${{ steps.docker_meta_img.outputs.labels }} - name: Docker meta Service Name (file preview) id: docker_meta_img_file_storage - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | @@ -85,11 +86,11 @@ jobs: - name: Set up Docker Buildx (file preview) if: ${{ env.IMAGE_EXISTS == 0 }} - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push ${{ github.repository }} (file preview) if: ${{ env.IMAGE_EXISTS == 0 }} - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: build-args: | BASE_IMAGE=ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }} @@ -97,6 +98,7 @@ jobs: file: ./Dockerfile.filepreview platforms: linux/amd64 push: true + pull: true tags: ghcr.io/${{ github.repository }}:file-preview-${{ needs.branch_meta.outputs.sha }} labels: | ${{ steps.docker_meta_img_file_storage.outputs.labels }} @@ -146,7 +148,6 @@ jobs: DEV_KUBE_CONFIG_NBC: ${{ secrets.DEV_KUBE_CONFIG_NBC }} DEV_KUBE_CONFIG_THR: ${{ secrets.DEV_KUBE_CONFIG_THR }} DEV_KUBE_CONFIG_DBC: ${{ secrets.DEV_KUBE_CONFIG_DBC }} - BINGO_REPO_TOKEN: ${{ secrets.BINGO_REPO_TOKEN }} deploy-successful: needs: diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 408a499f373..d7ce041375d 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -13,12 +13,12 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Docker meta Service Name for docker hub id: docker_meta_img_hub - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: docker.io/schulcloud/schulcloud-server, quay.io/schulcloudverbund/schulcloud-server tags: | @@ -39,19 +39,20 @@ jobs: password: ${{ secrets.QUAY_TOKEN }} - name: Build and push ${{ github.repository }} - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64 pull: true push: true + pull: true tags: ${{ steps.docker_meta_img_hub.outputs.tags }} labels: ${{ steps.docker_meta_img_hub.outputs.labels }} - name: Docker meta Service Name for docker hub (file preview) id: docker_meta_img_hub_file_storage - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: docker.io/schulcloud/schulcloud-server, quay.io/schulcloudverbund/schulcloud-server tags: | @@ -60,7 +61,7 @@ jobs: labels: | org.opencontainers.image.title=schulcloud-file-storage - name: Build and push ${{ github.repository }} (file-storage) - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: build-args: | BASE_IMAGE=quay.io/schulcloudverbund/schulcloud-server:${{ github.ref_name }} @@ -68,6 +69,7 @@ jobs: file: ./Dockerfile.filepreview platforms: linux/amd64 push: true + pull: true tags: ${{ steps.docker_meta_img_hub_file_storage.outputs.tags }} labels: ${{ steps.docker_meta_img_hub_file_storage.outputs.labels }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b087f1bc51..5aaa8567005 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 30 steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Start MongoDB @@ -47,9 +47,9 @@ jobs: - 5672:5672 steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Start MongoDB @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest needs: [nest_tests_cov] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/download-artifact@v3 @@ -89,7 +89,7 @@ jobs: distribution: 'temurin' java-version: '17' - name: SonarCloud upload coverage - uses: SonarSource/sonarcloud-github-action@v2.0.2 + uses: SonarSource/sonarcloud-github-action@v2.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} @@ -98,9 +98,9 @@ jobs: timeout-minutes: 6 steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: npm ci diff --git a/.github/workflows/test_unstable_e2e.yml b/.github/workflows/test_unstable_e2e.yml index 85119ba8b0d..89c613dc022 100644 --- a/.github/workflows/test_unstable_e2e.yml +++ b/.github/workflows/test_unstable_e2e.yml @@ -18,7 +18,7 @@ jobs: # run the action, when label 'run unstable tests' has been set if: "contains( github.event.label.name , 'run unstable tests' ) || contains( github.event.pull_request.labels.*.name , 'run unstable tests' )" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set BRANCH_NAME on pull_request run: | echo ${{ github.head_ref }} From a8d50bc383489e1811938c42a9fe7eb4d39edd5f Mon Sep 17 00:00:00 2001 From: Phillip Date: Tue, 19 Dec 2023 17:42:11 +0100 Subject: [PATCH 02/25] BC-6088 devcluster split preparation (#4656) change the call to the namespace activator to an internal one so we don't have the dependency on the domain here --- .../schulcloud-server-init/templates/configmap_file_init.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 1b430280d7e..7fb3b5f52dd 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -26,7 +26,7 @@ data: echo "gg, hacky mongo replicaset" fi {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} - curl -XPUT -H 'Content-Type: application/json' -L 'https://activate.cd.dbildungscloud.dev/namespace' -d '{"name" : "{{ NAMESPACE }}"}' + curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' {% endif %} echo "seeding database" curl --retry 360 --retry-all-errors --retry-delay 10 -X POST 'http://mgmt-svc:3333/api/management/database/seed?with-indexes=true' From 2a7b5ae70f0d198efbfefb4db20f05cec6defc90 Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Tue, 19 Dec 2023 18:17:22 +0100 Subject: [PATCH 03/25] fix unstable API tests (#4657) --- .../deletion-request-create.api.spec.ts | 104 +++++++++++++++--- 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts index d389b997944..3616cac13dc 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts +++ b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts @@ -11,6 +11,46 @@ import { DeletionRequestEntity } from '../../entity'; const baseRouteName = '/deletionRequests'; +const getMinDeletionPlannedAt = (creationDate: Date, diffInMinutes: number, toleranceInSeconds: number): Date => { + const minDeletionPlannedAt = new Date(creationDate.getTime()); + + minDeletionPlannedAt.setMinutes(minDeletionPlannedAt.getMinutes() + diffInMinutes); + minDeletionPlannedAt.setSeconds(minDeletionPlannedAt.getSeconds() - toleranceInSeconds); + + return minDeletionPlannedAt; +}; + +const getMaxDeletionPlannedAt = (creationDate: Date, diffInMinutes: number, toleranceInSeconds: number): Date => { + const maxDeletionPlannedAt = new Date(creationDate.getTime()); + + maxDeletionPlannedAt.setMinutes(maxDeletionPlannedAt.getMinutes() + diffInMinutes); + maxDeletionPlannedAt.setSeconds(maxDeletionPlannedAt.getSeconds() + toleranceInSeconds); + + return maxDeletionPlannedAt; +}; + +const getMinAndMaxDeletionPlannedAt = (creationDate: Date, diffInMinutes: number, toleranceInSeconds: number) => { + const minDeletionPlannedAt = getMinDeletionPlannedAt(creationDate, diffInMinutes, toleranceInSeconds); + const maxDeletionPlannedAt = getMaxDeletionPlannedAt(creationDate, diffInMinutes, toleranceInSeconds); + + return { minDeletionPlannedAt, maxDeletionPlannedAt }; +}; + +const isDeletionPlannedWithinAcceptableRange = ( + creationDate: Date, + deletionPlannedAt: Date, + diffInMinutes: number, + toleranceInSeconds: number +) => { + const { minDeletionPlannedAt, maxDeletionPlannedAt } = getMinAndMaxDeletionPlannedAt( + creationDate, + diffInMinutes, + toleranceInSeconds + ); + + return deletionPlannedAt >= minDeletionPlannedAt && deletionPlannedAt <= maxDeletionPlannedAt; +}; + describe(`deletionRequest create (api)`, () => { let app: INestApplication; let em: EntityManager; @@ -42,7 +82,7 @@ describe(`deletionRequest create (api)`, () => { }); describe('createDeletionRequests', () => { - describe('when create deletionRequest', () => { + describe('when called', () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { @@ -59,7 +99,16 @@ describe(`deletionRequest create (api)`, () => { deleteInMinutes: 0, }; - return { deletionRequestToCreate, deletionRequestToImmediateRemoval }; + const defaultDeleteInMinutes = 43200; + + const operationalTimeToleranceInSeconds = 30; + + return { + deletionRequestToCreate, + deletionRequestToImmediateRemoval, + defaultDeleteInMinutes, + operationalTimeToleranceInSeconds, + }; }; it('should return status 202', async () => { @@ -79,33 +128,52 @@ describe(`deletionRequest create (api)`, () => { expect(result.requestId).toBeDefined(); }); - it('should create deletionRequest with default deletion time (add 43200 minutes to current time) ', async () => { - const { deletionRequestToCreate } = setup(); + describe('when the "delete in minutes" param has not been provided', () => { + it( + 'should set the "deletion planned at" date to the date after the default "delete in minutes" value ' + + '(43200 minutes which is 30 days), with some operational time tolerance', + async () => { + const { deletionRequestToCreate, defaultDeleteInMinutes, operationalTimeToleranceInSeconds } = setup(); - const response = await testXApiKeyClient.post('', deletionRequestToCreate); + const response = await testXApiKeyClient.post('', deletionRequestToCreate); - const result = response.body as DeletionRequestResponse; - const createdDeletionRequestId = result.requestId; + const result = response.body as DeletionRequestResponse; + const createdDeletionRequestId = result.requestId; - const createdItem = await em.findOneOrFail(DeletionRequestEntity, createdDeletionRequestId); + const createdItem = await em.findOneOrFail(DeletionRequestEntity, createdDeletionRequestId); - const deletionPlannedAt = createdItem.createdAt; - deletionPlannedAt.setMinutes(deletionPlannedAt.getMinutes() + 43200); + const isDeletionPlannedAtDateCorrect = isDeletionPlannedWithinAcceptableRange( + createdItem.createdAt, + createdItem.deleteAfter, + defaultDeleteInMinutes, + operationalTimeToleranceInSeconds + ); - expect(createdItem.deleteAfter).toEqual(deletionPlannedAt); + expect(isDeletionPlannedAtDateCorrect).toEqual(true); + } + ); }); - it('should create deletionRequest with deletion time (0 minutes to current time) ', async () => { - const { deletionRequestToImmediateRemoval } = setup(); + describe('when the "delete in minutes" param has been set to 0', () => { + it('should set the "deletion planned at" date to now, with some operational time tolerance', async () => { + const { deletionRequestToImmediateRemoval, operationalTimeToleranceInSeconds } = setup(); - const response = await testXApiKeyClient.post('', deletionRequestToImmediateRemoval); + const response = await testXApiKeyClient.post('', deletionRequestToImmediateRemoval); - const result = response.body as DeletionRequestResponse; - const createdDeletionRequestId = result.requestId; + const result = response.body as DeletionRequestResponse; + const createdDeletionRequestId = result.requestId; + + const createdItem = await em.findOneOrFail(DeletionRequestEntity, createdDeletionRequestId); - const createdItem = await em.findOneOrFail(DeletionRequestEntity, createdDeletionRequestId); + const isDeletionPlannedAtDateCorrect = isDeletionPlannedWithinAcceptableRange( + createdItem.createdAt, + createdItem.deleteAfter, + 0, + operationalTimeToleranceInSeconds + ); - expect(createdItem.createdAt).toEqual(createdItem.deleteAfter); + expect(isDeletionPlannedAtDateCorrect).toEqual(true); + }); }); }); }); From 188b14d5a0d9946e53a5551783dfb92d51641b3f Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Wed, 20 Dec 2023 15:49:42 +0100 Subject: [PATCH 04/25] BC-5658 - remove duplicate flag (#4660) --- .github/workflows/tag.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index d7ce041375d..6089c0739a1 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -44,7 +44,6 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64 - pull: true push: true pull: true tags: ${{ steps.docker_meta_img_hub.outputs.tags }} From 612b02914a723a34d94c7db19547a8f857e431a1 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Thu, 21 Dec 2023 14:36:48 +0100 Subject: [PATCH 05/25] N21-1623 fix class visibility for course (#4659) * class visibility fix * use new schoolService --- .../interface/class-request-context.enum.ts | 4 ++ .../group/controller/dto/interface/index.ts | 1 + .../dto/request/class-caller-params.ts | 10 ++++ .../group/controller/dto/request/index.ts | 1 + .../group/controller/group.controller.ts | 3 + .../src/modules/group/group-api.module.ts | 2 + .../src/modules/group/uc/group.uc.spec.ts | 55 ++++++++++++++----- apps/server/src/modules/group/uc/group.uc.ts | 17 ++++-- .../modules/school/domain/do/school.spec.ts | 39 +++++++++++++ .../src/modules/school/domain/do/school.ts | 6 ++ 10 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/modules/group/controller/dto/interface/class-request-context.enum.ts create mode 100644 apps/server/src/modules/group/controller/dto/request/class-caller-params.ts diff --git a/apps/server/src/modules/group/controller/dto/interface/class-request-context.enum.ts b/apps/server/src/modules/group/controller/dto/interface/class-request-context.enum.ts new file mode 100644 index 00000000000..5f24479c2e8 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/interface/class-request-context.enum.ts @@ -0,0 +1,4 @@ +export enum ClassRequestContext { + COURSE = 'course', + CLASS_OVERVIEW = 'class-overview', +} diff --git a/apps/server/src/modules/group/controller/dto/interface/index.ts b/apps/server/src/modules/group/controller/dto/interface/index.ts index fa69bb70b30..a94213271b7 100644 --- a/apps/server/src/modules/group/controller/dto/interface/index.ts +++ b/apps/server/src/modules/group/controller/dto/interface/index.ts @@ -1,2 +1,3 @@ export * from './class-sort-by.enum'; export * from './school-year-query-type.enum'; +export { ClassRequestContext } from './class-request-context.enum'; diff --git a/apps/server/src/modules/group/controller/dto/request/class-caller-params.ts b/apps/server/src/modules/group/controller/dto/request/class-caller-params.ts new file mode 100644 index 00000000000..60765802ffb --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/class-caller-params.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ClassRequestContext } from '../interface'; + +export class ClassCallerParams { + @IsOptional() + @IsEnum(ClassRequestContext) + @ApiPropertyOptional({ enum: ClassRequestContext, enumName: 'ClassRequestContext' }) + calledFrom?: ClassRequestContext; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts index cb39cb2f9c5..e7da3889f23 100644 --- a/apps/server/src/modules/group/controller/dto/request/index.ts +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -2,3 +2,4 @@ export * from './class-sort-params'; export * from './group-id-params'; export * from './class-filter-params'; export { GroupPaginationParams } from './group-pagination.params'; +export { ClassCallerParams } from './class-caller-params'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index 7a5117466b7..e5df1dec514 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -12,6 +12,7 @@ import { GroupIdParams, GroupResponse, GroupPaginationParams, + ClassCallerParams, } from './dto'; import { GroupResponseMapper } from './mapper'; @@ -30,12 +31,14 @@ export class GroupController { @Query() pagination: GroupPaginationParams, @Query() sortingQuery: ClassSortParams, @Query() filterParams: ClassFilterParams, + @Query() callerParams: ClassCallerParams, @CurrentUser() currentUser: ICurrentUser ): Promise { const board: Page = await this.groupUc.findAllClasses( currentUser.userId, currentUser.schoolId, filterParams.type, + callerParams.calledFrom, pagination.skip, pagination.limit, sortingQuery.sortBy, diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts index 1f2de66c02a..1b3d14645c6 100644 --- a/apps/server/src/modules/group/group-api.module.ts +++ b/apps/server/src/modules/group/group-api.module.ts @@ -6,6 +6,7 @@ import { LegacySchoolModule } from '@modules/legacy-school'; import { SystemModule } from '@modules/system'; import { UserModule } from '@modules/user'; import { LoggerModule } from '@src/core/logger'; +import { SchoolModule } from '@modules/school'; import { GroupController } from './controller'; import { GroupModule } from './group.module'; import { GroupUc } from './uc'; @@ -17,6 +18,7 @@ import { GroupUc } from './uc'; UserModule, RoleModule, LegacySchoolModule, + SchoolModule, AuthorizationModule, SystemModule, LoggerModule, diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index e49179202c1..3a53d6d2f80 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -4,7 +4,7 @@ import { Action, AuthorizationContext, AuthorizationService } from '@modules/aut import { ClassService } from '@modules/class'; import { Class } from '@modules/class/domain'; import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; -import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { LegacySystemService, SystemDto } from '@modules/system'; @@ -13,13 +13,12 @@ import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo, Page, UserDO } from '@shared/domain/domainobject'; +import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; import { Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { groupFactory, - legacySchoolDoFactory, roleDtoFactory, schoolYearFactory, setupEntities, @@ -28,7 +27,9 @@ import { userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { SchoolYearQueryType } from '../controller/dto/interface'; +import { School, SchoolService } from '@modules/school/domain'; +import { schoolFactory } from '@modules/school/testing'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; @@ -45,7 +46,7 @@ describe('GroupUc', () => { let systemService: DeepMocked; let userService: DeepMocked; let roleService: DeepMocked; - let schoolService: DeepMocked; + let schoolService: DeepMocked; let authorizationService: DeepMocked; let schoolYearService: DeepMocked; let logger: DeepMocked; @@ -75,8 +76,8 @@ describe('GroupUc', () => { useValue: createMock(), }, { - provide: LegacySchoolService, - useValue: createMock(), + provide: SchoolService, + useValue: createMock(), }, { provide: AuthorizationService, @@ -99,7 +100,7 @@ describe('GroupUc', () => { systemService = module.get(LegacySystemService); userService = module.get(UserService); roleService = module.get(RoleService); - schoolService = module.get(LegacySchoolService); + schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); schoolYearService = module.get(SchoolYearService); logger = module.get(Logger); @@ -118,7 +119,7 @@ describe('GroupUc', () => { describe('findAllClasses', () => { describe('when the user has no permission', () => { const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const school: School = schoolFactory.build(); const user: User = userFactory.buildWithId(); const error = new ForbiddenException(); @@ -146,7 +147,7 @@ describe('GroupUc', () => { describe('when accessing as a normal user', () => { const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const school: School = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); const { studentUser } = UserAndAccountTestFactory.buildStudent(); const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); const teacherRole: RoleDto = roleDtoFactory.buildWithId({ @@ -271,7 +272,7 @@ describe('GroupUc', () => { await uc.findAllClasses(teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR); - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, LegacySchoolDo, AuthorizationContext]>( + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( teacherUser, school, { @@ -292,6 +293,26 @@ describe('GroupUc', () => { ]); }); + describe('when accessing form course as a teacher', () => { + it('should call findClassesForSchool method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.COURSE); + + expect(classService.findClassesForSchool).toHaveBeenCalled(); + }); + }); + + describe('when accessing form class overview as a teacher', () => { + it('should call findAllByUserId method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.CLASS_OVERVIEW); + + expect(classService.findAllByUserId).toHaveBeenCalled(); + }); + }); + describe('when no pagination is given', () => { it('should return all classes sorted by name', async () => { const { @@ -385,6 +406,7 @@ describe('GroupUc', () => { SchoolYearQueryType.CURRENT_YEAR, undefined, undefined, + undefined, 'externalSourceName', SortOrder.desc ); @@ -441,6 +463,7 @@ describe('GroupUc', () => { teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR, + undefined, 2, 1, 'name', @@ -523,7 +546,7 @@ describe('GroupUc', () => { describe('when accessing as a user with elevated permission', () => { const setup = (generateClasses = false) => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const school: School = schoolFactory.build(); const { studentUser } = UserAndAccountTestFactory.buildStudent(); const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); const { adminUser } = UserAndAccountTestFactory.buildAdmin(); @@ -652,7 +675,7 @@ describe('GroupUc', () => { await uc.findAllClasses(adminUser.id, adminUser.school.id); - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, LegacySchoolDo, AuthorizationContext]>( + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( adminUser, school, { @@ -733,6 +756,7 @@ describe('GroupUc', () => { undefined, undefined, undefined, + undefined, 'externalSourceName', SortOrder.desc ); @@ -778,6 +802,7 @@ describe('GroupUc', () => { adminUser.id, adminUser.school.id, undefined, + undefined, 1, 1, 'name', @@ -805,6 +830,7 @@ describe('GroupUc', () => { adminUser.id, adminUser.school.id, undefined, + undefined, 0, 5 ); @@ -819,6 +845,7 @@ describe('GroupUc', () => { adminUser.id, adminUser.school.id, undefined, + undefined, 0, -1 ); @@ -830,7 +857,7 @@ describe('GroupUc', () => { describe('when class has a user referenced which is not existing', () => { const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const school: School = schoolFactory.build(); const notFoundReferenceId = new ObjectId().toHexString(); const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 272019c5e77..18bb1c5e466 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,20 +1,21 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { ClassService } from '@modules/class'; import { Class } from '@modules/class/domain'; -import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { SortHelper } from '@shared/common'; import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; -import { LegacySchoolDo, Page, UserDO } from '@shared/domain/domainobject'; +import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; import { Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { LegacySystemService, SystemDto } from '@src/modules/system'; -import { SchoolYearQueryType } from '../controller/dto/interface'; +import { School, SchoolService } from '@modules/school/domain'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes, GroupUser } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; @@ -29,7 +30,7 @@ export class GroupUc { private readonly systemService: LegacySystemService, private readonly userService: UserService, private readonly roleService: RoleService, - private readonly schoolService: LegacySchoolService, + private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, private readonly schoolYearService: SchoolYearService, private readonly logger: Logger @@ -41,12 +42,13 @@ export class GroupUc { userId: EntityId, schoolId: EntityId, schoolYearQueryType?: SchoolYearQueryType, + calledFrom?: ClassRequestContext, skip = 0, limit?: number, sortBy: keyof ClassInfoDto = 'name', sortOrder: SortOrder = SortOrder.asc ): Promise> { - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + const school: School = await this.schoolService.getSchoolById(schoolId); const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkPermission( @@ -60,8 +62,11 @@ export class GroupUc { Permission.GROUP_FULL_ADMIN, ]); + const calledFromCourse: boolean = + calledFrom === ClassRequestContext.COURSE && school.getPermissions()?.teacher?.STUDENT_LIST === true; + let combinedClassInfo: ClassInfoDto[]; - if (canSeeFullList) { + if (canSeeFullList || calledFromCourse) { combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); } else { combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); diff --git a/apps/server/src/modules/school/domain/do/school.spec.ts b/apps/server/src/modules/school/domain/do/school.spec.ts index faa9214eaba..479f48f28f6 100644 --- a/apps/server/src/modules/school/domain/do/school.spec.ts +++ b/apps/server/src/modules/school/domain/do/school.spec.ts @@ -42,6 +42,45 @@ describe('School', () => { expect(school.getProps().features).not.toContain(feature); }); }); + // TODO N21-1623 add test for getPermissions + describe('getPermissions', () => { + describe('when permissions exist', () => { + const setup = () => { + const permissions = { teacher: { STUDENT_LIST: true } }; + const school = schoolFactory.build({ + permissions, + }); + + return { school, permissions }; + }; + + it('should return permissions', () => { + const { school, permissions } = setup(); + + const result = school.getPermissions(); + + expect(result).toEqual(permissions); + }); + }); + + describe('when permissions are undefined', () => { + const setup = () => { + const school = schoolFactory.build({ + permissions: undefined, + }); + + return { school }; + }; + + it('should return undefined', () => { + const { school } = setup(); + + const result = school.getPermissions(); + + expect(result).toBeUndefined(); + }); + }); + }); describe('isInMaintenance', () => { describe('when inMaintenanceSince is in the past', () => { diff --git a/apps/server/src/modules/school/domain/do/school.ts b/apps/server/src/modules/school/domain/do/school.ts index cb1ecca400f..0cfb3594c7e 100644 --- a/apps/server/src/modules/school/domain/do/school.ts +++ b/apps/server/src/modules/school/domain/do/school.ts @@ -14,6 +14,12 @@ export class School extends DomainObject { this.props.features.delete(feature); } + public getPermissions(): SchoolPermissions | undefined { + const { permissions } = this.props; + + return permissions; + } + public isInMaintenance(): boolean { const result = this.props.inMaintenanceSince ? this.props.inMaintenanceSince <= new Date() : false; From 7e1ab293c2ec5df48a23085d846f9f40a2f9a83a Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Thu, 21 Dec 2023 15:15:58 +0100 Subject: [PATCH 06/25] BC-6101 - fix mongoose connection (#4661) --- src/utils/database.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/utils/database.js b/src/utils/database.js index 1e184cd326c..2e3d12a70f8 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -26,7 +26,7 @@ const encodeMongoURI = (urlString) => { function addAuthenticationToMongooseOptions(username, password, mongooseOptions) { const auth = {}; if (username) { - auth.user = username; + auth.username = username; } if (password) { auth.password = password; @@ -78,13 +78,7 @@ function connect() { mongoose.Promise = global.Promise; const options = getConnectionOptions(); - logger.info( - 'connect to database host', - options.url, - options.username ? `with username ${options.username}` : 'without user', - options.password ? 'and' : 'and without', - 'password' - ); + logger.info('connect to database host'); const mongooseOptions = { autoIndex: NODE_ENV !== ENVIRONMENTS.PRODUCTION, From a13a01777fc52f14b1e1f6091dfba3c2068848e3 Mon Sep 17 00:00:00 2001 From: MBergCap <111343628+MBergCap@users.noreply.github.com> Date: Fri, 22 Dec 2023 11:39:35 +0100 Subject: [PATCH 07/25] N21-1587 protected custom parameter (#4658) * extend ctl - customParameter model with isProtected * update seed data (external tools) * migration script --------- Co-authored-by: Igor Richter --- .../tool/common/domain/custom-parameter.do.ts | 3 ++ .../context-external-tool.entity.spec.ts | 1 + .../api-test/tool-configuration.api.spec.ts | 4 ++ .../controller/api-test/tool.api.spec.ts | 4 ++ .../dto/request/custom-parameter.params.ts | 4 ++ .../dto/response/custom-parameter.response.ts | 4 ++ .../custom-parameter.entity.ts | 4 ++ .../entity/external-tool.entity.spec.ts | 1 + .../external-tool-request.mapper.spec.ts | 6 +++ .../mapper/external-tool-request.mapper.ts | 1 + .../external-tool-response.mapper.spec.ts | 3 ++ .../mapper/external-tool-response.mapper.ts | 1 + .../external-tool-version.service.spec.ts | 1 + .../school-external-tool.entity.spec.ts | 1 + .../external-tool.repo.integration.spec.ts | 1 + .../externaltool/external-tool.repo.mapper.ts | 2 + .../tool/external-tool.factory.ts | 1 + .../factory/external-tool-entity.factory.ts | 1 + backup/setup/external-tools.json | 27 ++++++---- backup/setup/migrations.json | 11 ++++ ...add-protected-field-to-custom-paramters.js | 53 +++++++++++++++++++ 21 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 migrations/1703072644729-add-protected-field-to-custom-paramters.js diff --git a/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts b/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts index fcad4784f43..6e34fabe86c 100644 --- a/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts +++ b/apps/server/src/modules/tool/common/domain/custom-parameter.do.ts @@ -21,6 +21,8 @@ export class CustomParameter { isOptional: boolean; + isProtected: boolean; + constructor(props: CustomParameter) { this.name = props.name; this.displayName = props.displayName; @@ -32,5 +34,6 @@ export class CustomParameter { this.regex = props.regex; this.regexComment = props.regexComment; this.isOptional = props.isOptional; + this.isProtected = props.isProtected; } } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts index 29f2b3080d1..b41519bbef5 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts @@ -43,6 +43,7 @@ describe('ExternalToolEntity', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ name: 'toolName', diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts index 1207c326ea8..812836a99da 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts @@ -203,6 +203,7 @@ describe('ToolConfigurationController (API)', () => { name: contextParameter.name, displayName: contextParameter.displayName, isOptional: contextParameter.isOptional, + isProtected: contextParameter.isProtected, defaultValue: contextParameter.default, description: contextParameter.description, regex: contextParameter.regex, @@ -361,6 +362,7 @@ describe('ToolConfigurationController (API)', () => { name: schoolParameter.name, displayName: schoolParameter.displayName, isOptional: schoolParameter.isOptional, + isProtected: schoolParameter.isProtected, defaultValue: schoolParameter.default, description: schoolParameter.description, regex: schoolParameter.regex, @@ -489,6 +491,7 @@ describe('ToolConfigurationController (API)', () => { name: schoolParameter.name, displayName: schoolParameter.displayName, isOptional: schoolParameter.isOptional, + isProtected: schoolParameter.isProtected, defaultValue: schoolParameter.default, description: schoolParameter.description, regex: schoolParameter.regex, @@ -644,6 +647,7 @@ describe('ToolConfigurationController (API)', () => { name: contextParameter.name, displayName: contextParameter.displayName, isOptional: contextParameter.isOptional, + isProtected: contextParameter.isProtected, defaultValue: contextParameter.default, description: contextParameter.description, regex: contextParameter.regex, 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 27f7e7825e4..93da1d956b2 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 @@ -74,6 +74,7 @@ describe('ToolController (API)', () => { displayName: 'User Friendly Name', defaultValue: 'abc', isOptional: false, + isProtected: false, type: CustomParameterTypeParams.STRING, regex: 'abc', regexComment: 'Regex accepts "abc" as value.', @@ -137,6 +138,7 @@ describe('ToolController (API)', () => { displayName: 'User Friendly Name', defaultValue: 'abc', isOptional: false, + isProtected: false, type: CustomParameterTypeParams.STRING, regex: 'abc', regexComment: 'Regex accepts "abc" as value.', @@ -367,6 +369,7 @@ describe('ToolController (API)', () => { displayName: 'User Friendly Name', defaultValue: 'abc', isOptional: false, + isProtected: false, type: CustomParameterTypeParams.STRING, regex: 'abc', regexComment: 'Regex accepts "abc" as value.', @@ -433,6 +436,7 @@ describe('ToolController (API)', () => { displayName: 'User Friendly Name', defaultValue: 'abc', isOptional: false, + isProtected: false, type: CustomParameterTypeParams.STRING, regex: 'abc', regexComment: 'Regex accepts "abc" as value.', diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts index c996183b5ba..2ab070d46a3 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/custom-parameter.params.ts @@ -52,4 +52,8 @@ export class CustomParameterPostParams { @IsBoolean() @ApiProperty() isOptional!: boolean; + + @IsBoolean() + @ApiProperty() + isProtected!: boolean; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts index 058bbdb5d0d..604d4f9cba7 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/custom-parameter.response.ts @@ -36,6 +36,9 @@ export class CustomParameterResponse { @ApiProperty() isOptional: boolean; + @ApiProperty() + isProtected: boolean; + constructor(props: CustomParameterResponse) { this.name = props.name; this.displayName = props.displayName; @@ -47,5 +50,6 @@ export class CustomParameterResponse { this.regex = props.regex; this.regexComment = props.regexComment; this.isOptional = props.isOptional; + this.isProtected = props.isProtected; } } diff --git a/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts index 3f99cfe52b5..e9d4833d0bc 100644 --- a/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/custom-parameter/custom-parameter.entity.ts @@ -33,6 +33,9 @@ export class CustomParameterEntity { @Property() isOptional: boolean; + @Property({ default: false }) + isProtected: boolean; + constructor(props: CustomParameterEntity) { this.name = props.name; this.displayName = props.displayName; @@ -44,5 +47,6 @@ export class CustomParameterEntity { this.regex = props.regex; this.regexComment = props.regexComment; this.isOptional = props.isOptional; + this.isProtected = props.isProtected; } } diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts index 933f007c440..9d037046942 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts @@ -48,6 +48,7 @@ describe('ExternalToolEntity', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ name: 'toolName', diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts index 72f5d35de5c..cf29d8e874c 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts @@ -71,6 +71,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolCreateParams = new ExternalToolCreateParams(); externalToolCreateParams.name = 'mockName'; @@ -159,6 +160,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolCreateParams = new ExternalToolCreateParams(); externalToolCreateParams.name = 'mockName'; @@ -244,6 +246,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolCreateParams = new ExternalToolCreateParams(); externalToolCreateParams.name = 'mockName'; @@ -312,6 +315,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolUpdateParams = new ExternalToolUpdateParams(); externalToolUpdateParams.id = 'id'; @@ -404,6 +408,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolUpdateParams = new ExternalToolUpdateParams(); externalToolUpdateParams.id = 'id'; @@ -493,6 +498,7 @@ describe('ExternalToolRequestMapper', () => { customParameterPostParams.regex = 'mockRegex'; customParameterPostParams.regexComment = 'mockComment'; customParameterPostParams.isOptional = false; + customParameterPostParams.isProtected = false; const externalToolUpdateParams = new ExternalToolUpdateParams(); externalToolUpdateParams.id = 'id'; 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 916e1c734a8..c57e0ee457a 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 @@ -153,6 +153,7 @@ export class ExternalToolRequestMapper { location: locationMapping[customParameterParam.location], type: typeMapping[customParameterParam.type], isOptional: customParameterParam.isOptional, + isProtected: customParameterParam.isProtected, }; }); } diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts index bdf612f944c..0a491222081 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts @@ -43,6 +43,7 @@ describe('ExternalToolResponseMapper', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const basicToolConfigResponse: BasicToolConfigResponse = new BasicToolConfigResponse({ @@ -143,6 +144,7 @@ describe('ExternalToolResponseMapper', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ @@ -230,6 +232,7 @@ describe('ExternalToolResponseMapper', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const externalToolResponse: ExternalToolResponse = new ExternalToolResponse({ diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts index eadbc20c50a..7be202132ef 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 @@ -94,6 +94,7 @@ export class ExternalToolResponseMapper { location: locationMapping[customParameterDO.location], type: typeMapping[customParameterDO.type], isOptional: customParameterDO.isOptional, + isProtected: customParameterDO.isProtected, }; }); } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts index dabfd70996e..f19eaeabe04 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts @@ -17,6 +17,7 @@ describe('ExternalToolVersionService', () => { displayName: 'displayName', default: 'defaulValueParam1', isOptional: false, + isProtected: false, location: CustomParameterLocation.PATH, regex: '*', regexComment: '', diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts index 17344cd0579..c3b03b2d0cd 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts @@ -41,6 +41,7 @@ describe('SchoolExternalToolEntity', () => { regex: 'mockRegex', regexComment: 'mockComment', isOptional: false, + isProtected: false, }); const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ name: 'toolName', diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index 75fa86597fb..e3e2949d5c1 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -152,6 +152,7 @@ describe('ExternalToolRepo', () => { location: CustomParameterLocation.BODY, regexComment: 'mockComment', isOptional: false, + isProtected: false, }), ], isHidden: true, diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index a5710bc3a81..7740647ea9f 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -149,6 +149,7 @@ export class ExternalToolRepoMapper { location: param.location, type: param.type, isOptional: param.isOptional, + isProtected: param.isProtected, }) ); } @@ -167,6 +168,7 @@ export class ExternalToolRepoMapper { location: param.location, type: param.type, isOptional: param.isOptional, + isProtected: param.isProtected, }) ); } diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts index 7815d863a69..d5ec2ee7371 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts @@ -79,6 +79,7 @@ export const customParameterFactory = CustomParameterFactory.define(CustomParame scope: CustomParameterScope.SCHOOL, location: CustomParameterLocation.BODY, isOptional: false, + isProtected: false, }; }); diff --git a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts index 39ff2662cf7..8f7c22247fd 100644 --- a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts @@ -85,6 +85,7 @@ export const customParameterEntityFactory = BaseFactory.define Date: Thu, 28 Dec 2023 09:28:30 +0100 Subject: [PATCH 08/25] BC-6111 - default system and federal state (#4665) --- backup/setup/migrations.json | 13 +++- .../1703253259864-school-default-props.js | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 migrations/1703253259864-school-default-props.js diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 93da7824804..10c4d4b98b6 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -375,7 +375,18 @@ }, { "_id": { - "$oid": "658434b45fcf755f84d80cb6" + "$oid": "6585950b4903600babed0d79" + }, + "state": "up", + "name": "school-default-props", + "createdAt": { + "$date": "2023-12-22T13:54:19.864Z" + }, + "__v": 0 + }, + { + "_id": { + "$oid": "6585979f8eef141a0615da6f" }, "state": "up", "name": "add-protected-field-to-custom-paramters", diff --git a/migrations/1703253259864-school-default-props.js b/migrations/1703253259864-school-default-props.js new file mode 100644 index 00000000000..7a9ef2be0a9 --- /dev/null +++ b/migrations/1703253259864-school-default-props.js @@ -0,0 +1,76 @@ +const mongoose = require('mongoose'); +// eslint-disable-next-line no-unused-vars +const { alert, error } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +const { Schema } = mongoose; + +const School = mongoose.model( + 'schools202312111053', + new mongoose.Schema( + { + systems: [{ type: Schema.Types.ObjectId, ref: 'system' }], + federalState: { type: Schema.Types.ObjectId, ref: 'federalstate' }, + }, + { + timestamps: true, + } + ), + 'schools' +); + +const FederalState = mongoose.model( + 'federalState202312111053', + new mongoose.Schema( + { + name: { type: String, required: true }, + }, + { + timestamps: true, + } + ), + 'federalstates' +); + +module.exports = { + up: async function up() { + await connect(); + + await School.updateMany( + { + systems: { $exists: false }, + }, + { + systems: [], + } + ) + .lean() + .exec(); + + const tenant = process.env.SC_THEME; + let federalStateName = 'Brandenburg'; + if (tenant !== 'n21') { + federalStateName = 'Niedersachsen'; + } else if (tenant === 'brb') { + federalStateName = 'Brandenburg'; + } else if (tenant === 'thr') { + federalStateName = 'Thüringen'; + } + + const federalState = await FederalState.findOne({ name: federalStateName }).lean().exec(); + + await School.updateMany( + { + federalState: { $exists: false }, + }, + { + federalState: federalState._id, + } + ) + .lean() + .exec(); + + await close(); + }, +}; From 3444c2ea1f48ddc960c71976a8b8d0a63183a1b4 Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Wed, 3 Jan 2024 14:12:05 +0100 Subject: [PATCH 09/25] BC-6131 - fix test factory for school year (#4673) improve school year integration tests for nest legacy school --- .../controller/api-test/group.api.spec.ts | 4 +- .../repo/schoolyear.repo.integration.spec.ts | 65 ++++++++++++++----- .../testing/factory/schoolyear.factory.ts | 10 ++- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 7d08a0fd2fa..32a9740cd7d 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -47,13 +47,13 @@ describe('Group (API)', () => { describe('[GET] /groups/class', () => { describe('when an admin requests a list of classes', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ currentYear: schoolYear }); const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); const system: SystemEntity = systemEntityFactory.buildWithId(); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', schoolId: school._id, diff --git a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts index 8f6f1f45e65..241200e81c2 100644 --- a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts @@ -46,30 +46,65 @@ describe('schoolyear repo', () => { }); describe('findCurrentYear', () => { - describe('when date is between schoolyears start and end date', () => { - const setup = async () => { - const schoolYear: SchoolYearEntity = schoolYearFactory.build({ - startDate: new Date('2020-08-01'), - endDate: new Date('9999-07-31'), + describe('when current date is between schoolyears start and end date', () => { + describe('when current date year is in the schoolyears start date', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2023-10-01')); + + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2023-08-01'), + endDate: new Date('2024-07-31'), + }); + + await em.persistAndFlush(schoolYear); + em.clear(); + + return { schoolYear }; + }; + + it('should return the current schoolyear', async () => { + const { schoolYear } = await setup(); + + const currentYear = await repo.findCurrentYear(); + + expect(currentYear).toEqual(schoolYear); }); + }); + describe('when current date year is in the schoolyears end date', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2024-03-01')); - await em.persistAndFlush(schoolYear); - em.clear(); + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2023-08-01'), + endDate: new Date('2024-07-31'), + }); - return { schoolYear }; - }; + await em.persistAndFlush(schoolYear); + em.clear(); + + return { schoolYear }; + }; - it('should return the current schoolyear', async () => { - const { schoolYear } = await setup(); + it('should return the current schoolyear', async () => { + const { schoolYear } = await setup(); - const currentYear = await repo.findCurrentYear(); + const currentYear = await repo.findCurrentYear(); - expect(currentYear).toEqual(schoolYear); + expect(currentYear).toEqual(schoolYear); + }); }); }); - describe('when date is not between schoolyears start and end date', () => { + describe('when current date is outside schoolyears start and end date', () => { const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2024-01-01')); + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ startDate: new Date('2020-08-01'), endDate: new Date('2021-07-31'), @@ -81,7 +116,7 @@ describe('schoolyear repo', () => { return { schoolYear }; }; - it('should return the current schoolyear', async () => { + it('should throw', async () => { await setup(); const func = () => repo.findCurrentYear(); diff --git a/apps/server/src/shared/testing/factory/schoolyear.factory.ts b/apps/server/src/shared/testing/factory/schoolyear.factory.ts index 5de956288e6..30b36d0f432 100644 --- a/apps/server/src/shared/testing/factory/schoolyear.factory.ts +++ b/apps/server/src/shared/testing/factory/schoolyear.factory.ts @@ -13,8 +13,14 @@ class SchoolYearFactory extends BaseFactory { - const startYearWithoutSequence = transientParams?.startYear ?? new Date().getFullYear(); - const startYear = startYearWithoutSequence + sequence - 1; + const now = new Date(); + const startYearWithoutSequence = transientParams?.startYear ?? now.getFullYear(); + + let step = 1; + if (now.getMonth() < 7) { + step = 2; + } + const startYear = startYearWithoutSequence + sequence - step; const name = `${startYear}/${(startYear + 1).toString().slice(-2)}`; const startDate = new Date(`${startYear}-08-01`); From 031d12df4996944209140764f5be6833c06781a2 Mon Sep 17 00:00:00 2001 From: Phillip Date: Wed, 3 Jan 2024 14:47:47 +0100 Subject: [PATCH 10/25] removed unused test code (#4670) --- test/services/rocketChat/index.test.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/services/rocketChat/index.test.js b/test/services/rocketChat/index.test.js index 78caf281737..db442894062 100644 --- a/test/services/rocketChat/index.test.js +++ b/test/services/rocketChat/index.test.js @@ -3,7 +3,6 @@ /* eslint-disable no-unused-expressions */ const assert = require('assert'); const chai = require('chai'); -const mockery = require('mockery'); const { ObjectId } = require('mongoose').Types; const appPromise = require('../../../src/app'); @@ -23,19 +22,11 @@ describe('rocket.chat user service', () => { before(async () => { app = await appPromise(); // const rcMock = await rcMockServer({}); - const rocketChatService = { + app.services['nest-rocket-chat'] = { getUserList: () => { return { users: [{ _id: 'someId', username: 'someUsername' }] }; }, }; - mockery.enable({ - warnOnUnregistered: false, - }); - - // ROCKET_CHAT_ADMIN_TOKEN, ROCKET_CHAT_ADMIN_ID - // mockery.registerMock('../../../config/globals', { ROCKET_CHAT_URI: rcMock.url }); - // const rocketChatService = { getUserList: sinon.spy() }; - app.services['nest-rocket-chat'] = rocketChatService; delete require.cache[require.resolve('../../../src/services/rocketChat/services/rocketChatUser.js')]; delete require.cache[require.resolve('../../../src/services/rocketChat/helpers.js')]; From 7e8b01dbc4e650b4417a4855cfb0d36567642e93 Mon Sep 17 00:00:00 2001 From: Phillip Date: Thu, 4 Jan 2024 20:28:12 +0100 Subject: [PATCH 11/25] BC-6136 adapted if conditions accoding to BC-6088 (#4678) speed up init job by moving activation logic to the first action --- .../templates/configmap_file_init.yml.j2 | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 7fb3b5f52dd..1592f4701d4 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -8,6 +8,9 @@ metadata: data: update.sh: | #! /bin/bash + {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} + curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' + {% endif %} # necessary for secret handling and legacy indexes git clone https://github.com/hpi-schul-cloud/schulcloud-server.git cd /schulcloud-server @@ -25,9 +28,6 @@ data: else echo "gg, hacky mongo replicaset" fi - {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} - curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' - {% endif %} echo "seeding database" curl --retry 360 --retry-all-errors --retry-delay 10 -X POST 'http://mgmt-svc:3333/api/management/database/seed?with-indexes=true' @@ -183,7 +183,7 @@ data: "grantType": "authorization_code", "scope": "openid", "responseType": "code", - "redirectUri": "https://{{ NAMESPACE }}.cd.dbildungscloud.dev/api/v3/sso/oauth", + "redirectUri": "https://{{ NAMESPACE }}.nbc.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/auth", "provider": "sanis", "jwksEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/certs", @@ -198,15 +198,15 @@ data: # ========== Start of the Dev IServ configuration section. - # This is currently performed only for the following 2 namespaces: - # - 'nbc-main', - # - 'nbc-iserv-test'; + # This is currently performed only for the following 2 namespaces on *dev-nbc*: + # - 'main', + # - 'iserv-test'; # the first one being the namespace of the default nbc dev environment and the second one being # the additional namespace intended for use for the testing (and development) purposes if one want # to test anything that includes signing in with the IServ on nbc instance, but don't want to use # the default dev nbc instance as it would require merging the code to the main branch first. - if [[ "$NS" =~ ^(nbc-main|nbc-iserv-test)$ ]]; then + if [ "$SC_THEME" = "n21" ] && [[ "$NS" =~ ^(main|iserv-test)$ ]]; then ISERV_SYSTEM_ID=0000d186816abba584714c92 # Encrypt secrets that contain IServ's OAuth client secret and LDAP server's search user password. @@ -245,7 +245,7 @@ data: "grantType": "authorization_code", "scope": "openid uuid", "responseType": "code", - "redirectUri": "https://'$NS'.cd.dbildungscloud.dev/api/v3/sso/oauth", + "redirectUri": "https://'$NS'.nbc.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "'$ISERV_URL'/iserv/auth/auth", "provider": "iserv", "logoutEndpoint": "'$ISERV_URL'/iserv/auth/logout", @@ -265,7 +265,7 @@ data: # This is currently performed for any 'brb-*' namespace ('brb-main' for example). - if [[ "$NS" =~ ^brb-[^\s]+$ ]]; then + if [ "$SC_THEME" = "brb" ]; then UNIVENTION_LDAP_SYSTEM_ID=621beef78ec63ea12a3adae6 UNIVENTION_LDAP_FEDERAL_STATE_ID=0000b186816abba584714c53 @@ -306,11 +306,9 @@ data: # ========== Start of the Bettermarks tool configuration section. - # This is currently performed only for the following 4 namespaces: - # - 'nbc-bettermarks-test', - # - 'nbc-main', - # - 'brb-bettermarks-test', - # - 'brb-main'; + # This is currently performed only for the following namespaces on dev for each tenant nbc and brb: + # - 'bettermarks-test' + # - 'main' # the first two being the testing environments for the nbc instances # and the last two being the testing environments for the brb instances. @@ -319,16 +317,16 @@ data: if [ -n "$NS" ]; then # Set the BETTERMARKS_CLIENT_SECRET and BETTERMARKS_URL variables values according to the k8s namespace. - if [ "$NS" = "nbc-bettermarks-test" ]; then + if [ "$SC_THEME" = "n21" ] && [ "$NS" = "bettermarks-test" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_BETTERMARKS_TEST_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_NBC_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$NS" = "nbc-main" ]; then + elif [ "$SC_THEME" = "n21" ] && [ "$NS" = "main" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_MAIN_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_NBC_MAIN_ENTRYPOINT - elif [ "$NS" = "brb-bettermarks-test" ]; then + elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "bettermarks-test" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_BETTERMARKS_TEST_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_BRB_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$NS" = "brb-main" ]; then + elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "main" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_MAIN_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_BRB_MAIN_ENTRYPOINT else From 0a672f28558d9ee3f36c16974b6c117d3947178e Mon Sep 17 00:00:00 2001 From: Michael Linares <116801315+Michaellinaresxk@users.noreply.github.com> Date: Fri, 5 Jan 2024 10:03:08 +0100 Subject: [PATCH 12/25] BC-5809 Exclude student from topic draft mode. (#4664) * Added restrictToUsersCoursesLessons to exclude student from topic draft. --- src/services/lesson/hooks/index.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/services/lesson/hooks/index.js b/src/services/lesson/hooks/index.js index 458b64785ab..a5923ce154f 100644 --- a/src/services/lesson/hooks/index.js +++ b/src/services/lesson/hooks/index.js @@ -3,7 +3,7 @@ const { Configuration } = require('@hpi-schul-cloud/commons'); const { nanoid } = require('nanoid'); const { iff, isProvider } = require('feathers-hooks-common'); -const { NotFound, BadRequest } = require('../../../errors'); +const { NotFound, BadRequest, Forbidden } = require('../../../errors'); const { equal } = require('../../../helper/compare').ObjectId; const { injectUserId, @@ -204,6 +204,19 @@ const restrictToUsersCoursesLessons = async (context) => { return context; }; +const restrictToUsersDraftLessons = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); + const userIsStudent = user.roles.filter((u) => u.name === 'student').length > 0; + const lesson = await context.app.service('lessons').get(context.id); + const isDraft = lesson.hidden; + + if (isDraft && userIsStudent) { + throw new Forbidden(`You don't have permission.`); + } + + return context; +}; + const populateWhitelist = { materialIds: [ '_id', @@ -236,12 +249,12 @@ exports.before = () => { hasPermission('TOPIC_VIEW'), iff(isProvider('external'), validateLessonFind), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), ], get: [ hasPermission('TOPIC_VIEW'), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), ], create: [ checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', true), @@ -254,7 +267,7 @@ exports.before = () => { iff(isProvider('external'), preventPopulate), permitGroupOperation, ifNotLocal(checkCorrectCourseOrTeamId), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), ], patch: [ From 6e99df301c2b4854359b91be10ff54365e704280 Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Fri, 5 Jan 2024 13:58:17 +0100 Subject: [PATCH 13/25] BC-5835 Add Task to KNL Deletion module (#4667) * add service in task, modify DomainModel interface * add test for domainOperatioNuilder, taskEntity * fix in deletion module imports&exports * fix in deletionController * add tests in taskRepo * add test for taskScope * add test for taskService * fixes in Task entity * connect deletion module with taskService * some fixes in task entity * change in task entity * Update apps/server/src/modules/deletion/deletion-api.module.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * move builder, fix imports * fix in taskService * splitting the removeCreatorId method * fix in deletion module, delete removeUserFromTasks from taskService * add findByUserIdInFinished method in repo and test cases for it * fix testcases in deletion UC * modify task services to delete user data * Update apps/server/src/modules/task/service/task.service.ts Co-authored-by: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> * fix after review * fix imports and add logger to services in TaskService * fix in imports --------- Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Co-authored-by: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> --- .../deletion-log-statistic.builder.spec.ts | 4 +- .../builder/deletion-log-statistic.builder.ts | 14 ++- ...eletion-request-body-props.builder.spec.ts | 4 +- .../deletion-request-body-props.builder.ts | 5 +- ...etion-request-log-response.builder.spec.ts | 4 +- .../deletion-request-log-response.builder.ts | 5 +- .../deletion-target-ref.builder.spec.ts | 4 +- .../builder/deletion-target-ref.builder.ts | 5 +- .../deletion-request-create.api.spec.ts | 6 +- .../dto/deletion-request-log.response.spec.ts | 4 +- .../dto/deletion-request-log.response.ts | 5 +- .../modules/deletion/deletion-api.module.ts | 4 +- .../deletion/domain/deletion-log.do.spec.ts | 5 +- .../deletion/domain/deletion-log.do.ts | 16 +-- .../domain/deletion-request.do.spec.ts | 5 +- .../deletion/domain/deletion-request.do.ts | 8 +- .../testing/factory/deletion-log.factory.ts | 5 +- .../factory/deletion-request.factory.ts | 5 +- .../modules/deletion/domain/types/index.ts | 1 - .../entity/deletion-log.entity.spec.ts | 5 +- .../deletion/entity/deletion-log.entity.ts | 30 ++--- .../entity/deletion-request.entity.spec.ts | 5 +- .../entity/deletion-request.entity.ts | 8 +- .../factory/deletion-log.entity.factory.ts | 5 +- .../deletion-request.entity.factory.ts | 5 +- .../modules/deletion/interface/interfaces.ts | 11 +- .../services/deletion-log.service.spec.ts | 7 +- .../deletion/services/deletion-log.service.ts | 6 +- .../services/deletion-request.service.spec.ts | 5 +- .../services/deletion-request.service.ts | 6 +- .../deletion/uc/deletion-request.uc.spec.ts | 55 ++++++++- .../deletion/uc/deletion-request.uc.ts | 112 ++++++++---------- .../deletion/uc/interface/interfaces.ts | 8 +- .../modules/task/service/task.service.spec.ts | 112 +++++++++++++++++- .../src/modules/task/service/task.service.ts | 65 +++++++++- apps/server/src/modules/task/task.module.ts | 3 +- .../builder/domain-operation.builder.spec.ts | 29 +++++ .../builder/domain-operation.builder.ts | 16 +++ .../server/src/shared/domain/builder/index.ts | 1 + .../shared/domain/entity/task.entity.spec.ts | 48 ++++++++ .../src/shared/domain/entity/task.entity.ts | 22 +++- .../domain/interface/domain-operation.ts | 9 ++ .../src/shared/domain/interface/index.ts | 1 + .../domain/types/domain.ts} | 3 +- apps/server/src/shared/domain/types/index.ts | 1 + .../src/shared/repo/task/task-scope.spec.ts | 47 ++++++++ .../server/src/shared/repo/task/task-scope.ts | 8 ++ .../repo/task/task.repo.integration.spec.ts | 76 ++++++++++++ apps/server/src/shared/repo/task/task.repo.ts | 27 +++++ 49 files changed, 663 insertions(+), 182 deletions(-) create mode 100644 apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts create mode 100644 apps/server/src/shared/domain/builder/domain-operation.builder.ts create mode 100644 apps/server/src/shared/domain/builder/index.ts create mode 100644 apps/server/src/shared/domain/interface/domain-operation.ts rename apps/server/src/{modules/deletion/domain/types/deletion-domain-model.enum.ts => shared/domain/types/domain.ts} (87%) create mode 100644 apps/server/src/shared/repo/task/task-scope.spec.ts diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts index babef08f8d9..a8a998e7a4e 100644 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder } from '.'; describe(DeletionLogStatisticBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionLogStatisticBuilder.name, () => { it('should build generic deletionLogStatistic with all attributes', () => { // Arrange - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const modifiedCount = 0; const deletedCount = 2; diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts index 2e467eed310..fa0680b8500 100644 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts @@ -1,9 +1,15 @@ -import { DeletionDomainModel } from '../domain/types'; -import { DeletionLogStatistic } from '../interface'; +import { DomainOperation } from '@shared/domain/interface'; +import { DomainModel } from '@shared/domain/types'; export class DeletionLogStatisticBuilder { - static build(domain: DeletionDomainModel, modifiedCount?: number, deletedCount?: number): DeletionLogStatistic { - const deletionLogStatistic = { domain, modifiedCount, deletedCount }; + static build( + domain: DomainModel, + modifiedCount: number, + deletedCount: number, + modifiedRef?: string[], + deletedRef?: string[] + ): DomainOperation { + const deletionLogStatistic = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; return deletionLogStatistic; } diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts index 4a363d86a40..dcde2f6adb3 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'bson'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestBodyPropsBuilder } from './deletion-request-body-props.builder'; describe(DeletionRequestBodyPropsBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionRequestBodyPropsBuilder.name, () => { }); describe('when create deletionRequestBodyParams', () => { const setup = () => { - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const refId = new ObjectId().toHexString(); const deleteInMinutes = 1000; return { domain, refId, deleteInMinutes }; diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts index 2105f7dfc0c..21e00fb7ab0 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts @@ -1,9 +1,8 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionRequestBodyProps } from '../controller/dto'; -import { DeletionDomainModel } from '../domain/types'; export class DeletionRequestBodyPropsBuilder { - static build(domain: DeletionDomainModel, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { + static build(domain: DomainModel, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { const deletionRequestItem = { targetRef: { domain, id }, deleteInMinutes, diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts index 6d02894b43f..718af3faf2d 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder, DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '.'; describe(DeletionRequestLogResponseBuilder, () => { @@ -8,7 +8,7 @@ describe(DeletionRequestLogResponseBuilder, () => { it('should build generic deletionRequestLog with all attributes', () => { // Arrange - const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefDomain = DomainModel.PSEUDONYMS; const targetRefId = '653e4833cc39e5907a1e18d2'; const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); const deletionPlannedAt = new Date(); diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts index be4b0ba5a96..04dccb52162 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts @@ -1,11 +1,12 @@ +import { DomainOperation } from '@shared/domain/interface'; import { DeletionRequestLogResponse } from '../controller/dto'; -import { DeletionLogStatistic, DeletionTargetRef } from '../interface'; +import { DeletionTargetRef } from '../interface'; export class DeletionRequestLogResponseBuilder { static build( targetRef: DeletionTargetRef, deletionPlannedAt: Date, - statistics?: DeletionLogStatistic[] + statistics?: DomainOperation[] ): DeletionRequestLogResponse { const deletionRequestLog = { targetRef, deletionPlannedAt, statistics }; diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts index 4667f290b80..762518d17bd 100644 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionTargetRefBuilder } from './index'; describe(DeletionTargetRefBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionTargetRefBuilder.name, () => { it('should build generic deletionTargetRef with all attributes', () => { // Arrange - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const refId = '653e4833cc39e5907a1e18d2'; const result = DeletionTargetRefBuilder.build(domain, refId); diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts index d1960a5d4a4..1d1cee14a04 100644 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts @@ -1,9 +1,8 @@ -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionTargetRef } from '../interface'; export class DeletionTargetRefBuilder { - static build(domain: DeletionDomainModel, id: EntityId): DeletionTargetRef { + static build(domain: DomainModel, id: EntityId): DeletionTargetRef { const deletionTargetRef = { domain, id }; return deletionTargetRef; diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts index 3616cac13dc..22814bf8590 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts +++ b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts @@ -5,8 +5,8 @@ import { AuthGuard } from '@nestjs/passport'; import { EntityManager } from '@mikro-orm/mongodb'; import { TestXApiKeyClient } from '@shared/testing'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestBodyProps, DeletionRequestResponse } from '../dto'; -import { DeletionDomainModel } from '../../domain/types'; import { DeletionRequestEntity } from '../../entity'; const baseRouteName = '/deletionRequests'; @@ -86,14 +86,14 @@ describe(`deletionRequest create (api)`, () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: '653e4833cc39e5907a1e18d2', }, }; const deletionRequestToImmediateRemoval: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: '653e4833cc39e5907a1e18d2', }, deleteInMinutes: 0, diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts index 5036a0d39e0..be91c23b06c 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts +++ b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'bson'; -import { DeletionDomainModel } from '../../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder, DeletionTargetRefBuilder } from '../../builder'; import { DeletionRequestLogResponse } from './index'; @@ -7,7 +7,7 @@ describe(DeletionRequestLogResponse.name, () => { describe('constructor', () => { describe('when passed properties', () => { const setup = () => { - const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefDomain = DomainModel.PSEUDONYMS; const targetRefId = new ObjectId().toHexString(); const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); const deletionPlannedAt = new Date(); diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts index 3619bebace8..e0b5d1546fe 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts +++ b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional } from 'class-validator'; -import { DeletionLogStatistic, DeletionTargetRef } from '../../interface'; +import { DomainOperation } from '@shared/domain/interface'; +import { DeletionTargetRef } from '../../interface'; export class DeletionRequestLogResponse { @ApiProperty() @@ -11,7 +12,7 @@ export class DeletionRequestLogResponse { @ApiProperty() @IsOptional() - statistics?: DeletionLogStatistic[]; + statistics?: DomainOperation[]; constructor(response: DeletionRequestLogResponse) { this.targetRef = response.targetRef; diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 8187f2fd306..5e4a6cf427d 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -14,10 +14,11 @@ import { RocketChatUserModule } from '@modules/rocketchat-user'; import { Configuration } from '@hpi-schul-cloud/commons'; import { RocketChatModule } from '@modules/rocketchat'; import { RegistrationPinModule } from '@modules/registration-pin'; +import { TaskModule } from '@modules/task'; +import { FilesStorageClientModule } from '@modules/files-storage-client'; import { DeletionRequestsController } from './controller/deletion-requests.controller'; import { DeletionExecutionsController } from './controller/deletion-executions.controller'; import { DeletionRequestUc } from './uc'; -import { FilesStorageClientModule } from '../files-storage-client'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { FilesStorageClientModule } from '../files-storage-client'; RocketChatUserModule, RegistrationPinModule, FilesStorageClientModule, + TaskModule, RocketChatModule.forRoot({ uri: Configuration.get('ROCKET_CHAT_URI') as string, adminId: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts index fd320ff79cb..10040956627 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts @@ -1,7 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { deletionLogFactory } from './testing/factory/deletion-log.factory'; import { DeletionLog } from './deletion-log.do'; -import { DeletionOperationModel, DeletionDomainModel } from './types'; +import { DeletionOperationModel } from './types'; describe(DeletionLog.name, () => { describe('constructor', () => { @@ -35,7 +36,7 @@ describe(DeletionLog.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.ts index c5ca2b652d1..81a9c7f41cb 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.ts +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.ts @@ -1,14 +1,14 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionDomainModel, DeletionOperationModel } from './types'; +import { DeletionOperationModel } from './types'; export interface DeletionLogProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - domain: DeletionDomainModel; + domain: DomainModel; operation?: DeletionOperationModel; - modifiedCount?: number; - deletedCount?: number; + modifiedCount: number; + deletedCount: number; deletionRequestId?: EntityId; performedAt?: Date; } @@ -22,7 +22,7 @@ export class DeletionLog extends DomainObject { return this.props.updatedAt; } - get domain(): DeletionDomainModel { + get domain(): DomainModel { return this.props.domain; } @@ -30,11 +30,11 @@ export class DeletionLog extends DomainObject { return this.props.operation; } - get modifiedCount(): number | undefined { + get modifiedCount(): number { return this.props.modifiedCount; } - get deletedCount(): number | undefined { + get deletedCount(): number { return this.props.deletedCount; } diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts index 1ffb7d3f906..2650e894fdc 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequest } from './deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from './types'; +import { DeletionStatusModel } from './types'; import { deletionRequestFactory } from './testing/factory/deletion-request.factory'; describe(DeletionRequest.name, () => { @@ -35,7 +36,7 @@ describe(DeletionRequest.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.ts index 76b7f0371ba..92010ef2570 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.ts +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.ts @@ -1,11 +1,11 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionDomainModel, DeletionStatusModel } from './types'; +import { DeletionStatusModel } from './types'; export interface DeletionRequestProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; deleteAfter: Date; targetRefId: EntityId; status: DeletionStatusModel; @@ -20,7 +20,7 @@ export class DeletionRequest extends DomainObject { return this.props.updatedAt; } - get targetRefDomain(): DeletionDomainModel { + get targetRefDomain(): DomainModel { return this.props.targetRefDomain; } diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts index 2a3d0529866..2593ba5c242 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts @@ -1,12 +1,13 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLog, DeletionLogProps } from '../../deletion-log.do'; -import { DeletionOperationModel, DeletionDomainModel } from '../../types'; +import { DeletionOperationModel } from '../../types'; export const deletionLogFactory = DoBaseFactory.define(DeletionLog, () => { return { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts index cf1f64daaec..e0ae6e7e41b 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts @@ -1,8 +1,9 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequest, DeletionRequestProps } from '../../deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from '../../types'; +import { DeletionStatusModel } from '../../types'; class DeletionRequestFactory extends DoBaseFactory { withUserIds(id: string): this { @@ -17,7 +18,7 @@ class DeletionRequestFactory extends DoBaseFactory { return { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/domain/types/index.ts b/apps/server/src/modules/deletion/domain/types/index.ts index d1f4de8eb6b..607e7fbe5e5 100644 --- a/apps/server/src/modules/deletion/domain/types/index.ts +++ b/apps/server/src/modules/deletion/domain/types/index.ts @@ -1,3 +1,2 @@ -export * from './deletion-domain-model.enum'; export * from './deletion-operation-model.enum'; export * from './deletion-status-model.enum'; diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts index c1b5f5f7184..a0524a30a52 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts @@ -1,7 +1,8 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogEntity } from './deletion-log.entity'; -import { DeletionOperationModel, DeletionDomainModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; describe(DeletionLogEntity.name, () => { beforeAll(async () => { @@ -13,7 +14,7 @@ describe(DeletionLogEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts index 31ec5447e56..03dfaf5123e 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts @@ -1,15 +1,15 @@ import { Entity, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { ObjectId } from 'bson'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; export interface DeletionLogEntityProps { id?: EntityId; - domain: DeletionDomainModel; + domain: DomainModel; operation?: DeletionOperationModel; - modifiedCount?: number; - deletedCount?: number; + modifiedCount: number; + deletedCount: number; deletionRequestId?: ObjectId; performedAt?: Date; createdAt?: Date; @@ -19,16 +19,16 @@ export interface DeletionLogEntityProps { @Entity({ tableName: 'deletionlogs' }) export class DeletionLogEntity extends BaseEntityWithTimestamps { @Property() - domain: DeletionDomainModel; + domain: DomainModel; @Property({ nullable: true }) operation?: DeletionOperationModel; - @Property({ nullable: true }) - modifiedCount?: number; + @Property() + modifiedCount: number; - @Property({ nullable: true }) - deletedCount?: number; + @Property() + deletedCount: number; @Property({ nullable: true }) deletionRequestId?: ObjectId; @@ -48,14 +48,8 @@ export class DeletionLogEntity extends BaseEntityWithTimestamps { if (props.operation !== undefined) { this.operation = props.operation; } - - if (props.modifiedCount !== undefined) { - this.modifiedCount = props.modifiedCount; - } - - if (props.deletedCount !== undefined) { - this.deletedCount = props.deletedCount; - } + this.modifiedCount = props.modifiedCount; + this.deletedCount = props.deletedCount; if (props.deletionRequestId !== undefined) { this.deletionRequestId = props.deletionRequestId; diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts index d4e8440bfa0..273679ef671 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts @@ -1,6 +1,7 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionRequestEntity } from '.'; describe(DeletionRequestEntity.name, () => { @@ -15,7 +16,7 @@ describe(DeletionRequestEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts index bff42fac93a..b81835641a9 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts @@ -1,12 +1,12 @@ import { Entity, Index, Property, Unique } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; +import { DeletionStatusModel } from '../domain/types'; const SECONDS_OF_90_DAYS = 90 * 24 * 60 * 60; export interface DeletionRequestEntityProps { id?: EntityId; - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; deleteAfter: Date; targetRefId: EntityId; status: DeletionStatusModel; @@ -25,7 +25,7 @@ export class DeletionRequestEntity extends BaseEntityWithTimestamps { targetRefId!: EntityId; @Property() - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; @Property() status: DeletionStatusModel; diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts index 93ed4198f15..6090f14402d 100644 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts @@ -1,14 +1,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; -import { DeletionOperationModel, DeletionDomainModel } from '../../../domain/types'; +import { DeletionOperationModel } from '../../../domain/types'; export const deletionLogEntityFactory = BaseFactory.define( DeletionLogEntity, () => { return { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts index cc65bb5f4dc..8f33e0d66d6 100644 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; -import { DeletionStatusModel, DeletionDomainModel } from '../../../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { DeletionStatusModel } from '../../../domain/types'; import { DeletionRequestEntity, DeletionRequestEntityProps } from '../../deletion-request.entity'; export const deletionRequestEntityFactory = BaseFactory.define( @@ -8,7 +9,7 @@ export const deletionRequestEntityFactory = BaseFactory.define { return { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/interface/interfaces.ts b/apps/server/src/modules/deletion/interface/interfaces.ts index 9d75c1c0da2..7a2621d87b7 100644 --- a/apps/server/src/modules/deletion/interface/interfaces.ts +++ b/apps/server/src/modules/deletion/interface/interfaces.ts @@ -1,13 +1,6 @@ -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; export interface DeletionTargetRef { - domain: DeletionDomainModel; + domain: DomainModel; id: EntityId; } - -export interface DeletionLogStatistic { - domain: DeletionDomainModel; - modifiedCount?: number; - deletedCount?: number; -} diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts index 7b63e866b14..e453ab24419 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts +++ b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogRepo } from '../repo'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; import { DeletionLogService } from './deletion-log.service'; import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; @@ -47,7 +48,7 @@ describe(DeletionLogService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const deletionRequestId = '653e4833cc39e5907a1e18d2'; - const domain = DeletionDomainModel.USER; + const domain = DomainModel.USER; const operation = DeletionOperationModel.DELETE; const modifiedCount = 0; const deletedCount = 1; @@ -82,7 +83,7 @@ describe(DeletionLogService.name, () => { const deletionLog1 = deletionLogFactory.build({ deletionRequestId }); const deletionLog2 = deletionLogFactory.build({ deletionRequestId, - domain: DeletionDomainModel.PSEUDONYMS, + domain: DomainModel.PSEUDONYMS, }); const deletionLogs = [deletionLog1, deletionLog2]; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.ts b/apps/server/src/modules/deletion/services/deletion-log.service.ts index a06458fe748..577e76a864b 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.ts +++ b/apps/server/src/modules/deletion/services/deletion-log.service.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionLog } from '../domain/deletion-log.do'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; import { DeletionLogRepo } from '../repo'; @Injectable() @@ -11,7 +11,7 @@ export class DeletionLogService { async createDeletionLog( deletionRequestId: EntityId, - domain: DeletionDomainModel, + domain: DomainModel, operation: DeletionOperationModel, modifiedCount: number, deletedCount: number diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts index 99763882064..d4675d62861 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts +++ b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts @@ -2,10 +2,11 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestService } from './deletion-request.service'; import { DeletionRequestRepo } from '../repo'; import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DeletionStatusModel } from '../domain/types'; describe(DeletionRequestService.name, () => { let module: TestingModule; @@ -47,7 +48,7 @@ describe(DeletionRequestService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const targetRefId = '653e4833cc39e5907a1e18d2'; - const targetRefDomain = DeletionDomainModel.USER; + const targetRefDomain = DomainModel.USER; return { targetRefId, targetRefDomain }; }; diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.ts b/apps/server/src/modules/deletion/services/deletion-request.service.ts index d3a41e03c12..08c282a8693 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.ts +++ b/apps/server/src/modules/deletion/services/deletion-request.service.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionRequest } from '../domain/deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionRequestRepo } from '../repo/deletion-request.repo'; @Injectable() @@ -11,7 +11,7 @@ export class DeletionRequestService { async createDeletionRequest( targetRefId: EntityId, - targetRefDomain: DeletionDomainModel, + targetRefDomain: DomainModel, deleteInMinutes = 43200 ): Promise<{ requestId: EntityId; deletionPlannedAt: Date }> { const dateOfDeletion = new Date(); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index bcdba26070a..cd7f0a00443 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -15,14 +15,17 @@ import { LegacyLogger } from '@src/core/logger'; import { ObjectId } from 'bson'; import { RegistrationPinService } from '@modules/registration-pin'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { TaskService } from '@modules/task'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; import { DeletionRequestUc } from './deletion-request.uc'; import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; import { deletionLogFactory } from '../domain/testing'; import { DeletionRequestBodyProps } from '../controller/dto'; -import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder, DeletionLogStatisticBuilder } from '../builder'; +import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; describe(DeletionRequestUc.name, () => { let module: TestingModule; @@ -43,6 +46,7 @@ describe(DeletionRequestUc.name, () => { let registrationPinService: DeepMocked; let filesStorageClientAdapterService: DeepMocked; let dashboardService: DeepMocked; + let taskService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -116,6 +120,10 @@ describe(DeletionRequestUc.name, () => { provide: DashboardService, useValue: createMock(), }, + { + provide: TaskService, + useValue: createMock(), + }, ], }).compile(); @@ -136,6 +144,7 @@ describe(DeletionRequestUc.name, () => { registrationPinService = module.get(RegistrationPinService); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); dashboardService = module.get(DashboardService); + taskService = module.get(TaskService); await setupEntities(); }); @@ -148,7 +157,7 @@ describe(DeletionRequestUc.name, () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: new ObjectId().toHexString(), }, deleteInMinutes: 1440, @@ -200,6 +209,9 @@ describe(DeletionRequestUc.name, () => { userId: deletionRequestToExecute.targetRefId, }); const parentEmail = 'parent@parent.eu'; + const tasksModifiedByRemoveCreatorId = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const tasksModifiedByRemoveUserFromFinished = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const tasksDeleted = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); @@ -214,6 +226,9 @@ describe(DeletionRequestUc.name, () => { rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); filesStorageClientAdapterService.removeCreatorIdFromFileRecords.mockResolvedValueOnce(5); dashboardService.deleteDashboardByUserId.mockResolvedValueOnce(1); + taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveCreatorId); + taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveUserFromFinished); + taskService.deleteTasksByOnlyCreator.mockResolvedValueOnce(tasksDeleted); return { deletionRequestToExecute, @@ -418,6 +433,36 @@ describe(DeletionRequestUc.name, () => { expect(dashboardService.deleteDashboardByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); }); + it('should call taskService.deleteTasksByOnlyCreator to delete Tasks only with creator', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.deleteTasksByOnlyCreator).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call taskService.removeCreatorIdFromTasks to update Tasks without creatorId', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.removeCreatorIdFromTasks).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call taskService.removeUserFromFinished to update Tasks without creatorId in Finished collection', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.removeUserFromFinished).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { const { deletionRequestToExecute } = setup(); @@ -425,7 +470,7 @@ describe(DeletionRequestUc.name, () => { await uc.executeDeletionRequests(); - expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(12); + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(13); }); }); @@ -470,7 +515,7 @@ describe(DeletionRequestUc.name, () => { deletionRequestExecuted.targetRefDomain, deletionRequestExecuted.targetRefId ); - const statistics = DeletionLogStatisticBuilder.build( + const statistics = DomainOperationBuilder.build( deletionLogExecuted.domain, deletionLogExecuted.modifiedCount, deletionLogExecuted.deletedCount diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index 7d705b23b09..97d2284a4cc 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -10,14 +10,16 @@ import { RocketChatUserService } from '@modules/rocketchat-user'; import { TeamService } from '@modules/teams'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DeletionLogStatisticBuilder, DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; +import { TaskService } from '@modules/task'; +import { DomainOperation } from '@shared/domain/interface'; +import { DomainOperationBuilder } from '@shared/domain/builder/domain-operation.builder'; +import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from '../controller/dto'; -import { DeletionLogStatistic } from './interface/interfaces'; import { DeletionRequest, DeletionLog } from '../domain'; -import { DeletionDomainModel, DeletionOperationModel, DeletionStatusModel } from '../domain/types'; +import { DeletionOperationModel, DeletionStatusModel } from '../domain/types'; import { DeletionRequestService, DeletionLogService } from '../services'; @Injectable() @@ -39,7 +41,8 @@ export class DeletionRequestUc { private readonly logger: LegacyLogger, private readonly registrationPinService: RegistrationPinService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly dashboardService: DashboardService + private readonly dashboardService: DashboardService, + private readonly taskService: TaskService ) { this.logger.setContext(DeletionRequestUc.name); } @@ -79,10 +82,10 @@ export class DeletionRequestUc { if (deletionRequest.status === DeletionStatusModel.SUCCESS) { const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); - const deletionLogStatistic: DeletionLogStatistic[] = deletionLog.map((log) => - DeletionLogStatisticBuilder.build(log.domain, log.modifiedCount, log.deletedCount) + const domainOperation: DomainOperation[] = deletionLog.map((log) => + DomainOperationBuilder.build(log.domain, log.modifiedCount, log.deletedCount) ); - response = { ...response, statistics: deletionLogStatistic }; + response = { ...response, statistics: domainOperation }; } return response; @@ -110,6 +113,7 @@ export class DeletionRequestUc { this.removeUserFromRocketChat(deletionRequest), this.removeUserRegistrationPin(deletionRequest), this.removeUsersDashboard(deletionRequest), + this.removeUserFromTasks(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -120,27 +124,25 @@ export class DeletionRequestUc { private async logDeletion( deletionRequest: DeletionRequest, - domainModel: DeletionDomainModel, + domainModel: DomainModel, operationModel: DeletionOperationModel, updatedCount: number, deletedCount: number ): Promise { - if (updatedCount > 0 || deletedCount > 0) { - await this.deletionLogService.createDeletionLog( - deletionRequest.id, - domainModel, - operationModel, - updatedCount, - deletedCount - ); - } + await this.deletionLogService.createDeletionLog( + deletionRequest.id, + domainModel, + operationModel, + updatedCount, + deletedCount + ); } private async removeAccount(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeAccount', deletionRequest }); await this.accountService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); + await this.logDeletion(deletionRequest, DomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); } private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { @@ -155,7 +157,7 @@ export class DeletionRequestUc { await this.logDeletion( deletionRequest, - DeletionDomainModel.REGISTRATIONPIN, + DomainModel.REGISTRATIONPIN, DeletionOperationModel.DELETE, 0, deletedRegistrationPin @@ -166,13 +168,7 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromClasses', deletionRequest }); const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.CLASS, - DeletionOperationModel.UPDATE, - classesUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.CLASS, DeletionOperationModel.UPDATE, classesUpdated, 0); } private async removeUserFromCourseGroup(deletionRequest: DeletionRequest) { @@ -183,7 +179,7 @@ export class DeletionRequestUc { ); await this.logDeletion( deletionRequest, - DeletionDomainModel.COURSEGROUP, + DomainModel.COURSEGROUP, DeletionOperationModel.UPDATE, courseGroupUpdated, 0 @@ -194,26 +190,14 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromCourse', deletionRequest }); const courseUpdated: number = await this.courseService.deleteUserDataFromCourse(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.COURSE, - DeletionOperationModel.UPDATE, - courseUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.COURSE, DeletionOperationModel.UPDATE, courseUpdated, 0); } private async removeUsersDashboard(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUsersDashboard', deletionRequest }); const dashboardDeleted: number = await this.dashboardService.deleteDashboardByUserId(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.DASHBOARD, - DeletionOperationModel.DELETE, - 0, - dashboardDeleted - ); + await this.logDeletion(deletionRequest, DomainModel.DASHBOARD, DeletionOperationModel.DELETE, 0, dashboardDeleted); } private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { @@ -225,7 +209,7 @@ export class DeletionRequestUc { ); await this.logDeletion( deletionRequest, - DeletionDomainModel.FILE, + DomainModel.FILE, DeletionOperationModel.UPDATE, filesDeleted + filePermissionsUpdated, 0 @@ -241,7 +225,7 @@ export class DeletionRequestUc { await this.logDeletion( deletionRequest, - DeletionDomainModel.FILERECORDS, + DomainModel.FILERECORDS, DeletionOperationModel.UPDATE, fileRecordsUpdated, 0 @@ -252,40 +236,28 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromLessons', deletionRequest }); const lessonsUpdated: number = await this.lessonService.deleteUserDataFromLessons(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.LESSONS, - DeletionOperationModel.UPDATE, - lessonsUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.LESSONS, DeletionOperationModel.UPDATE, lessonsUpdated, 0); } private async removeUsersPseudonyms(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUsersPseudonyms', deletionRequest }); const pseudonymDeleted: number = await this.pseudonymService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.PSEUDONYMS, - DeletionOperationModel.DELETE, - 0, - pseudonymDeleted - ); + await this.logDeletion(deletionRequest, DomainModel.PSEUDONYMS, DeletionOperationModel.DELETE, 0, pseudonymDeleted); } private async removeUserFromTeams(deletionRequest: DeletionRequest) { this.logger.debug({ action: ' removeUserFromTeams', deletionRequest }); const teamsUpdated: number = await this.teamService.deleteUserDataFromTeams(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); + await this.logDeletion(deletionRequest, DomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); } private async removeUser(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUser', deletionRequest }); const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); + await this.logDeletion(deletionRequest, DomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); } private async removeUserFromRocketChat(deletionRequest: DeletionRequest) { @@ -299,10 +271,28 @@ export class DeletionRequestUc { ]); await this.logDeletion( deletionRequest, - DeletionDomainModel.ROCKETCHATUSER, + DomainModel.ROCKETCHATUSER, DeletionOperationModel.DELETE, 0, rocketChatUserDeleted ); } + + private async removeUserFromTasks(deletionRequest: DeletionRequest) { + this.logger.debug({ action: 'removeUserFromTasks', deletionRequest }); + + const tasksDeleted = await this.taskService.deleteTasksByOnlyCreator(deletionRequest.targetRefId); + const tasksModifiedByRemoveCreator = await this.taskService.removeCreatorIdFromTasks(deletionRequest.targetRefId); + const tasksModifiedByRemoveUserFromFinished = await this.taskService.removeUserFromFinished( + deletionRequest.targetRefId + ); + + await this.logDeletion( + deletionRequest, + DomainModel.TASK, + DeletionOperationModel.UPDATE, + tasksModifiedByRemoveCreator.modifiedCount + tasksModifiedByRemoveUserFromFinished.modifiedCount, + tasksDeleted.deletedCount + ); + } } diff --git a/apps/server/src/modules/deletion/uc/interface/interfaces.ts b/apps/server/src/modules/deletion/uc/interface/interfaces.ts index 57275b5b530..004484c20b2 100644 --- a/apps/server/src/modules/deletion/uc/interface/interfaces.ts +++ b/apps/server/src/modules/deletion/uc/interface/interfaces.ts @@ -1,8 +1,8 @@ import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DomainModel } from '@shared/domain/types/domain'; export interface DeletionTargetRef { - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; targetRefId: EntityId; } @@ -13,13 +13,13 @@ export interface DeletionRequestLog { } export interface DeletionLogStatistic { - domain: DeletionDomainModel; + domain: DomainModel; modifiedCount?: number; deletedCount?: number; } export interface DeletionRequestProps { - targetRef: { targetRefDoamin: DeletionDomainModel; targetRefId: EntityId }; + targetRef: { targetRefDoamin: DomainModel; targetRefId: EntityId }; deleteInMinutes?: number; } diff --git a/apps/server/src/modules/task/service/task.service.spec.ts b/apps/server/src/modules/task/service/task.service.spec.ts index 4cf950964bd..fd77063c934 100644 --- a/apps/server/src/modules/task/service/task.service.spec.ts +++ b/apps/server/src/modules/task/service/task.service.spec.ts @@ -1,8 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { TaskRepo } from '@shared/repo'; -import { setupEntities, submissionFactory, taskFactory } from '@shared/testing'; +import { courseFactory, setupEntities, submissionFactory, taskFactory, userFactory } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { DomainModel } from '@shared/domain/types'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { LegacyLogger } from '@src/core/logger'; import { SubmissionService } from './submission.service'; import { TaskService } from './task.service'; @@ -29,6 +32,10 @@ describe('TaskService', () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: LegacyLogger, + useValue: createMock(), + }, ], }).compile(); @@ -102,4 +109,107 @@ describe('TaskService', () => { expect(taskRepo.delete).toBeCalledWith(task); }); }); + + describe('deleteTasksByOnlyCreator', () => { + describe('when task has only user as parent', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const taskWithoutCourse = taskFactory.buildWithId({ creator }); + + taskRepo.findByOnlyCreatorId.mockResolvedValue([[taskWithoutCourse], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); + + return { creator, expectedResult }; + }; + + it('should call taskRepo.findByOnlyCreatorId with creatorId', async () => { + const { creator } = setup(); + + await taskService.deleteTasksByOnlyCreator(creator.id); + + expect(taskRepo.findByOnlyCreatorId).toBeCalledWith(creator.id); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.deleteTasksByOnlyCreator(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeCreatorIdFromTasks', () => { + describe('when tasks where user is parent, and when task has course', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const course = courseFactory.build(); + const taskWithCourse = taskFactory.buildWithId({ creator, course }); + + taskRepo.findByCreatorIdWithCourseAndLesson.mockResolvedValue([[taskWithCourse], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const taskWithCourseToUpdate = { ...taskWithCourse, creator: undefined }; + + return { creator, expectedResult, taskWithCourseToUpdate }; + }; + + it('should call taskRepo.findByCreatorIdWithCourseAndLesson with creatorId', async () => { + const { creator } = setup(); + + await taskService.removeCreatorIdFromTasks(creator.id); + + expect(taskRepo.findByCreatorIdWithCourseAndLesson).toBeCalledWith(creator.id); + }); + + it('should call taskRepo.save with task to update', async () => { + const { creator, taskWithCourseToUpdate } = setup(); + + await taskService.removeCreatorIdFromTasks(creator.id); + + expect(taskRepo.save).toBeCalledWith([taskWithCourseToUpdate]); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.removeCreatorIdFromTasks(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeUserFromFinished', () => { + describe('when task has user in finished array', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const finishedTask = taskFactory.finished(creator).buildWithId(); + + taskRepo.findByUserIdInFinished.mockResolvedValue([[finishedTask], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + + return { creator, expectedResult }; + }; + + it('should call taskRepo.findByUserIdInFinished with creatorId', async () => { + const { creator } = setup(); + + await taskService.removeUserFromFinished(creator.id); + + expect(taskRepo.findByUserIdInFinished).toBeCalledWith(creator.id); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.removeUserFromFinished(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); }); diff --git a/apps/server/src/modules/task/service/task.service.ts b/apps/server/src/modules/task/service/task.service.ts index 3af74484604..0472c23bf42 100644 --- a/apps/server/src/modules/task/service/task.service.ts +++ b/apps/server/src/modules/task/service/task.service.ts @@ -1,9 +1,11 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { Task } from '@shared/domain/entity'; -import { IFindOptions } from '@shared/domain/interface'; -import { Counted, EntityId } from '@shared/domain/types'; +import { DomainOperation, IFindOptions } from '@shared/domain/interface'; +import { Counted, DomainModel, EntityId } from '@shared/domain/types'; import { TaskRepo } from '@shared/repo'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { LegacyLogger } from '@src/core/logger'; import { SubmissionService } from './submission.service'; @Injectable() @@ -11,8 +13,11 @@ export class TaskService { constructor( private readonly taskRepo: TaskRepo, private readonly submissionService: SubmissionService, - private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService - ) {} + private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, + private readonly logger: LegacyLogger + ) { + this.logger.setContext(TaskService.name); + } async findBySingleParent( creatorId: EntityId, @@ -41,4 +46,56 @@ export class TaskService { async findById(taskId: EntityId): Promise { return this.taskRepo.findById(taskId); } + + async deleteTasksByOnlyCreator(creatorId: EntityId): Promise { + this.logger.log(`Deleting Tasks where creatorId ${creatorId} is only parent`); + const [tasksByOnlyCreatorId, counterOfTasksOnlyWithCreatorId] = await this.taskRepo.findByOnlyCreatorId(creatorId); + + if (counterOfTasksOnlyWithCreatorId > 0) { + const promiseDeletedTasks = tasksByOnlyCreatorId.map((task: Task) => this.delete(task)); + await Promise.all(promiseDeletedTasks); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, 0, counterOfTasksOnlyWithCreatorId); + this.logger.log( + `Successfully deleted ${counterOfTasksOnlyWithCreatorId} where creatorId ${creatorId} is only parent` + ); + + return result; + } + + async removeCreatorIdFromTasks(creatorId: EntityId): Promise { + this.logger.log(`Deleting creatorId ${creatorId} from Tasks`); + const [tasksByCreatorIdWithCoursesAndLessons, counterOfTasksWithCoursesorLessons] = + await this.taskRepo.findByCreatorIdWithCourseAndLesson(creatorId); + + if (counterOfTasksWithCoursesorLessons > 0) { + tasksByCreatorIdWithCoursesAndLessons.forEach((task: Task) => task.removeCreatorId()); + await this.taskRepo.save(tasksByCreatorIdWithCoursesAndLessons); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithCoursesorLessons, 0); + this.logger.log(`Successfully updated ${counterOfTasksWithCoursesorLessons} Tasks without creatorId ${creatorId}`); + return result; + } + + async removeUserFromFinished(userId: EntityId): Promise { + this.logger.log(`Deleting userId ${userId} from Archve collection in Tasks`); + const [tasksWithUserInFinished, counterOfTasksWithUserInFinished] = await this.taskRepo.findByUserIdInFinished( + userId + ); + + if (counterOfTasksWithUserInFinished > 0) { + tasksWithUserInFinished.forEach((task: Task) => task.removeUserFromFinished(userId)); + + await this.taskRepo.save(tasksWithUserInFinished); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithUserInFinished, 0); + this.logger.log( + `Successfully updated ${counterOfTasksWithUserInFinished} Tasks without userId ${userId} in archive collection in Tasks` + ); + + return result; + } } diff --git a/apps/server/src/modules/task/task.module.ts b/apps/server/src/modules/task/task.module.ts index 87ecf144798..bd68fa8c5ef 100644 --- a/apps/server/src/modules/task/task.module.ts +++ b/apps/server/src/modules/task/task.module.ts @@ -2,10 +2,11 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; import { CourseRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { SubmissionService, TaskCopyService, TaskService } from './service'; @Module({ - imports: [FilesStorageClientModule, CopyHelperModule], + imports: [FilesStorageClientModule, CopyHelperModule, LoggerModule], providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, CourseRepo, SubmissionRepo], exports: [TaskService, TaskCopyService, SubmissionService], }) diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts new file mode 100644 index 00000000000..940224c3cf7 --- /dev/null +++ b/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts @@ -0,0 +1,29 @@ +import { DomainModel } from '@shared/domain/types'; +import { ObjectId } from 'bson'; +import { DomainOperationBuilder } from '.'; + +describe(DomainOperationBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const domain = DomainModel.PSEUDONYMS; + const modifiedCount = 0; + const modifiedRef = []; + const deletedRef = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const deletedCount = 2; + + return { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; + }; + + it('should build generic domainOperation with all attributes', () => { + const { domain, modifiedCount, deletedCount, modifiedRef, deletedRef } = setup(); + + const result = DomainOperationBuilder.build(domain, modifiedCount, deletedCount, modifiedRef, deletedRef); + + expect(result.domain).toEqual(domain); + expect(result.modifiedCount).toEqual(modifiedCount); + expect(result.deletedCount).toEqual(deletedCount); + }); +}); diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.ts new file mode 100644 index 00000000000..b9c6619482f --- /dev/null +++ b/apps/server/src/shared/domain/builder/domain-operation.builder.ts @@ -0,0 +1,16 @@ +import { DomainOperation } from '@shared/domain/interface'; +import { DomainModel } from '@shared/domain/types'; + +export class DomainOperationBuilder { + static build( + domain: DomainModel, + modifiedCount: number, + deletedCount: number, + modifiedRef?: string[], + deletedRef?: string[] + ): DomainOperation { + const domainOperation = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; + + return domainOperation; + } +} diff --git a/apps/server/src/shared/domain/builder/index.ts b/apps/server/src/shared/domain/builder/index.ts new file mode 100644 index 00000000000..5f9d180968d --- /dev/null +++ b/apps/server/src/shared/domain/builder/index.ts @@ -0,0 +1 @@ +export * from './domain-operation.builder'; diff --git a/apps/server/src/shared/domain/entity/task.entity.spec.ts b/apps/server/src/shared/domain/entity/task.entity.spec.ts index 8f0d1ebd278..a8a1007a898 100644 --- a/apps/server/src/shared/domain/entity/task.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/task.entity.spec.ts @@ -866,4 +866,52 @@ describe('Task Entity', () => { expect(schoolId).toEqual(school.id); }); }); + + describe('removeCreatorId is called', () => { + describe('WHEN creatorId exists', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const task = taskFactory.buildWithId({ creator: user }); + + return { task }; + }; + + it('should set it to undefined', () => { + const { task } = setup(); + + const result = task.removeCreatorId(); + + expect(result).toBe(undefined); + }); + }); + }); + + describe('removeUserFromFinished', () => { + describe('when user exist in Finished array', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const task = taskFactory.buildWithId({ finished: [user1, user2] }); + + return { user1, user2, task }; + }; + + it('should remove user form finished collection', () => { + const { task, user1 } = setup(); + + task.removeUserFromFinished(user1.id); + + expect(task.finished.contains(user1)).toBe(false); + }); + + it('should remove only user selected, not other users in finished collection', () => { + const { task, user1, user2 } = setup(); + + task.removeUserFromFinished(user1.id); + + expect(task.finished.contains(user1)).toBe(false); + expect(task.finished.contains(user2)).toBe(true); + }); + }); + }); }); diff --git a/apps/server/src/shared/domain/entity/task.entity.ts b/apps/server/src/shared/domain/entity/task.entity.ts index 88af11a0a7d..c5ae56b7286 100644 --- a/apps/server/src/shared/domain/entity/task.entity.ts +++ b/apps/server/src/shared/domain/entity/task.entity.ts @@ -67,8 +67,8 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, teamSubmissions?: boolean; @Index() - @ManyToOne('User', { fieldName: 'teacherId' }) - creator: User; + @ManyToOne('User', { fieldName: 'teacherId', nullable: true }) + creator?: User; @Index() @ManyToOne('Course', { fieldName: 'courseId', nullable: true }) @@ -128,7 +128,7 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, return finishedIds; } - private getParent(): TaskParent | User { + private getParent(): TaskParent | User | undefined { const parent = this.lesson || this.course || this.creator; return parent; @@ -136,9 +136,11 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, private getMaxSubmissions(): number { const parent = this.getParent(); - // For draft (user as parent) propaly user is not a student, but for maxSubmission one is valid result - const maxSubmissions = parent instanceof User ? 1 : parent.getStudentIds().length; - + let maxSubmissions = 0; + if (parent) { + // For draft (user as parent) propaly user is not a student, but for maxSubmission one is valid result + maxSubmissions = parent instanceof User ? 1 : parent.getStudentIds().length; + } return maxSubmissions; } @@ -321,6 +323,14 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, public unpublish(): void { this.private = true; } + + public removeCreatorId(): void { + this.creator = undefined; + } + + public removeUserFromFinished(userId: EntityId): void { + this.finished.remove((u) => u.id === userId); + } } export function isTask(reference: unknown): reference is Task { diff --git a/apps/server/src/shared/domain/interface/domain-operation.ts b/apps/server/src/shared/domain/interface/domain-operation.ts new file mode 100644 index 00000000000..d4900ed2314 --- /dev/null +++ b/apps/server/src/shared/domain/interface/domain-operation.ts @@ -0,0 +1,9 @@ +import { DomainModel } from '../types'; + +export interface DomainOperation { + domain: DomainModel; + modifiedCount: number; + deletedCount: number; + modifiedRef?: string[]; + deletedRef?: string[]; +} diff --git a/apps/server/src/shared/domain/interface/index.ts b/apps/server/src/shared/domain/interface/index.ts index 6c6b3f9d2db..72b97b4fa75 100644 --- a/apps/server/src/shared/domain/interface/index.ts +++ b/apps/server/src/shared/domain/interface/index.ts @@ -5,3 +5,4 @@ export * from './learnroom'; export * from './permission.enum'; export * from './rolename.enum'; export * from './video-conference-scope.enum'; +export * from './domain-operation'; diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/shared/domain/types/domain.ts similarity index 87% rename from apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts rename to apps/server/src/shared/domain/types/domain.ts index 922187e2a16..babc23631d1 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/shared/domain/types/domain.ts @@ -1,4 +1,4 @@ -export const enum DeletionDomainModel { +export const enum DomainModel { ACCOUNT = 'account', CLASS = 'class', COURSEGROUP = 'courseGroup', @@ -10,6 +10,7 @@ export const enum DeletionDomainModel { PSEUDONYMS = 'pseudonyms', REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', + TASK = 'task', TEAMS = 'teams', USER = 'user', } diff --git a/apps/server/src/shared/domain/types/index.ts b/apps/server/src/shared/domain/types/index.ts index b47a0d4821b..e591c94c825 100644 --- a/apps/server/src/shared/domain/types/index.ts +++ b/apps/server/src/shared/domain/types/index.ts @@ -10,3 +10,4 @@ export * from './school-purpose.enum'; export * from './system.type'; export * from './task.types'; export * from './value-of'; +export * from './domain'; diff --git a/apps/server/src/shared/repo/task/task-scope.spec.ts b/apps/server/src/shared/repo/task/task-scope.spec.ts new file mode 100644 index 00000000000..9fe21d66fd0 --- /dev/null +++ b/apps/server/src/shared/repo/task/task-scope.spec.ts @@ -0,0 +1,47 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { FilterQuery } from '@mikro-orm/core'; +import { Task } from '@shared/domain/entity'; +import { EmptyResultQuery } from '../query'; +import { TaskScope } from './task-scope'; + +describe(TaskScope.name, () => { + describe('when build scope query', () => { + const setup = () => { + const scope = new TaskScope(); + const creatorId = new ObjectId().toHexString(); + + const expected = { + $and: [ + { + creator: creatorId, + }, + { + $or: [ + { + course: { $ne: null }, + }, + { + lesson: { $ne: null }, + }, + ], + }, + ], + } as FilterQuery; + + return { scope, creatorId, expected }; + }; + it('should create valid query returning no results for empty scope', () => { + const { scope } = setup(); + const result = scope.query; + + expect(result).toBe(EmptyResultQuery); + }); + it('should create correct query for byCreatorIdWithCourseAndLesson', () => { + const { scope, creatorId, expected } = setup(); + scope.byCreatorIdWithCourseAndLesson(creatorId); + const result = scope.query; + + expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); + }); + }); +}); diff --git a/apps/server/src/shared/repo/task/task-scope.ts b/apps/server/src/shared/repo/task/task-scope.ts index 200c40b0aad..0b6446f0953 100644 --- a/apps/server/src/shared/repo/task/task-scope.ts +++ b/apps/server/src/shared/repo/task/task-scope.ts @@ -28,6 +28,14 @@ export class TaskScope extends Scope { return this; } + byCreatorIdWithCourseAndLesson(creatorId: EntityId): TaskScope { + this.addQuery({ + $and: [{ creator: creatorId }, { $or: [{ course: { $ne: null } }, { lesson: { $ne: null } }] }], + }); + + return this; + } + byCourseIds(courseIds: EntityId[]): TaskScope { this.addQuery({ $and: [{ course: { $in: courseIds } }, { lesson: null }], diff --git a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts index ea3b486c399..7b383c427bc 100644 --- a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts @@ -2105,4 +2105,80 @@ describe('TaskRepo', () => { }).rejects.toThrow(); }); }); + + describe('findByOnlyCreatorId', () => { + describe('when searching by creatorId', () => { + const setup = async () => { + const creator = userFactory.build(); + const course = courseFactory.build({ teachers: [creator] }); + const task = taskFactory.build({ creator }); + const taskWithCourse = taskFactory.build({ course, creator }); + + await em.persistAndFlush([task, taskWithCourse]); + em.clear(); + + return { creator }; + }; + + it('should find task where is only creator', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByOnlyCreatorId(creator.id); + + expect(result).toHaveLength(1); + }); + }); + }); + + describe('findByCreatorIdWithCourseAndLesson', () => { + describe('when searching by creatorId', () => { + const setup = async () => { + const creator = userFactory.build(); + const task = taskFactory.build({ creator }); + + const course = courseFactory.build({ teachers: [creator] }); + const taskWithCourse = taskFactory.build({ course, creator }); + + const lesson = lessonFactory.build({ course }); + const taskWithCourseAndLesson = taskFactory.build({ course, creator, lesson }); + + await em.persistAndFlush([task, taskWithCourse, taskWithCourseAndLesson]); + em.clear(); + + return { creator }; + }; + + it('should find task where are lesson or course', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByCreatorIdWithCourseAndLesson(creator.id); + + expect(result).toHaveLength(2); + }); + }); + }); + + describe('findByUserIdInFinished', () => { + describe('when searching by userId', () => { + const setup = async () => { + const creator = userFactory.build(); + const course = courseFactory.build({ teachers: [creator] }); + const taskWithFinished = taskFactory.build({ creator, course, finished: [creator] }); + const taskWithoutFinished = taskFactory.build({ creator, course }); + + await em.persistAndFlush([taskWithFinished, taskWithoutFinished]); + em.clear(); + + return { creator }; + }; + + it('should find task where user is in archive', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByUserIdInFinished(creator.id); + + expect(result).toHaveLength(1); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/task/task.repo.ts b/apps/server/src/shared/repo/task/task.repo.ts index 094d21a8dc5..b3e9d415d64 100644 --- a/apps/server/src/shared/repo/task/task.repo.ts +++ b/apps/server/src/shared/repo/task/task.repo.ts @@ -187,6 +187,33 @@ export class TaskRepo extends BaseRepo { return countedTaskList; } + async findByOnlyCreatorId(creatorId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byOnlyCreatorId(creatorId); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + + async findByCreatorIdWithCourseAndLesson(creatorId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byCreatorIdWithCourseAndLesson(creatorId); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + + async findByUserIdInFinished(userId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byFinished(userId, true); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + private async findTasksAndCount(query: FilterQuery, options?: IFindOptions): Promise> { const pagination = options?.pagination || {}; const order = options?.order || {}; From 59a64f769e0eaf45031c435feb15e28c05d5a66a Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Mon, 8 Jan 2024 13:11:31 +0100 Subject: [PATCH 14/25] N21-1563 ldap-sync-multi-teacher-cn (#4679) * N21-1563 adds possibility to have multiple cns for teacher role in systems ldapConfig.providerOptions.roleAttributeNameMapping --- .../templates/configmap_file_init.yml.j2 | 4 +-- src/services/ldap/strategies/general.js | 17 ++++++++----- test/services/ldap/strategies/general.test.js | 25 ++++++++++++++++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 1592f4701d4..aaf09038461 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -68,7 +68,7 @@ data: }, "roleAttributeNameMapping" : { "roleStudent" : "cn=ROLE_STUDENT,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", - "roleTeacher" : "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", + "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", "roleAdmin" : "cn=ROLE_ADMIN,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", "roleNoSc" : "cn=ROLE_NBC_EXCLUDE,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org" }, @@ -116,7 +116,7 @@ data: }, "roleAttributeNameMapping" : { "roleStudent" : "cn=ROLE_STUDENT,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", - "roleTeacher" : "cn=ROLE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", + "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", "roleAdmin" : "cn=ROLE_ADMIN,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", "roleNoSc" : "cn=ROLE_NBC_EXCLUDE,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org" }, diff --git a/src/services/ldap/strategies/general.js b/src/services/ldap/strategies/general.js index b70000e1c94..d6974544eb3 100644 --- a/src/services/ldap/strategies/general.js +++ b/src/services/ldap/strategies/general.js @@ -67,6 +67,7 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { ); } + const splittedTeacherRoles = roleAttributeNameMapping.roleTeacher.split(';;'); const results = []; ldapUsers.forEach((obj) => { const roles = []; @@ -77,9 +78,11 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { if (obj.memberOf.includes(roleAttributeNameMapping.roleStudent)) { roles.push('student'); } - if (obj.memberOf.includes(roleAttributeNameMapping.roleTeacher)) { - roles.push('teacher'); - } + splittedTeacherRoles.forEach((role) => { + if (obj.memberOf.includes(role)) { + roles.push('teacher'); + } + }); if (obj.memberOf.includes(roleAttributeNameMapping.roleAdmin)) { roles.push('administrator'); } @@ -90,9 +93,11 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleStudent) { roles.push('student'); } - if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleTeacher) { - roles.push('teacher'); - } + splittedTeacherRoles.forEach((role) => { + if (obj[userAttributeNameMapping.role].includes(role)) { + roles.push('teacher'); + } + }); if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleAdmin) { roles.push('administrator'); } diff --git a/test/services/ldap/strategies/general.test.js b/test/services/ldap/strategies/general.test.js index b938717cfad..7328811f892 100644 --- a/test/services/ldap/strategies/general.test.js +++ b/test/services/ldap/strategies/general.test.js @@ -23,7 +23,8 @@ const mockLDAPConfig = { }, roleAttributeNameMapping: { roleStudent: 'cn=ROLE_STUDENT,ou=roles,o=school0,dc=de,dc=example,dc=org', - roleTeacher: 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org', + roleTeacher: + 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org;;cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org', roleAdmin: 'cn=ROLE_ADMIN,ou=roles,o=school0,dc=de,dc=example,dc=org', }, classAttributeNameMapping: { @@ -137,6 +138,18 @@ describe('GeneralLDAPStrategy', () => { mail: 'testington.1@example.org', memberOf: 'cn=ROLE_ADMIN,ou=roles,o=school0,dc=de,dc=example,dc=org', }, + { + dn: 'uid=herr.anwalt,ou=users,o=school0,dc=de,dc=example,dc=org', + givenName: 'Herr', + sn: 'Anwalt', + uid: 'herr.anwalt', + uuid: 'ZDg0Y2ZlMjMtZGYwNi00MWNjLTg3YmUtZjI3NjA1NDJhY2Y4', + mail: 'herr.lempel.1@example.org', + memberOf: [ + 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org', + 'cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org', + ], + }, ]), }; } @@ -150,7 +163,7 @@ describe('GeneralLDAPStrategy', () => { it('should return all users', async () => { const instance = new GeneralLDAPStrategy(app, mockLDAPConfig); const users = await instance.getUsers(); - expect(users.length).to.equal(4); + expect(users.length).to.equal(5); }); it('should follow the internal interface', async () => { @@ -178,11 +191,15 @@ describe('GeneralLDAPStrategy', () => { }); it('should assign roles based on specific group memberships for group role type', async () => { - const [student1, student2, teacher, admin] = await new GeneralLDAPStrategy(app, mockLDAPConfig).getUsers(); + const [student1, student2, teacher, admin, teacher2] = await new GeneralLDAPStrategy( + app, + mockLDAPConfig + ).getUsers(); expect(student1.roles).to.include('student'); expect(student2.roles).to.include('student'); expect(teacher.roles).to.include('teacher'); expect(admin.roles).to.include('administrator'); + expect(teacher2.roles).to.include('teacher'); }); it('should assign roles based on specific group memberships for non-group role type', async () => { @@ -220,7 +237,7 @@ describe('GeneralLDAPStrategy', () => { }), createLDAPUserResult({ givenName: '', - memberOf: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleTeacher, + memberOf: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleTeacher.split(';;')[0], }), createLDAPUserResult({ givenName: '', From e3d923fab9c9bfda17487a4050405eea745878b4 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Mon, 8 Jan 2024 14:19:27 +0100 Subject: [PATCH 15/25] N21-1563 fixes seed data (#4682) --- .../schulcloud-server-init/templates/configmap_file_init.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index aaf09038461..cb2ad5bba9e 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -116,7 +116,7 @@ data: }, "roleAttributeNameMapping" : { "roleStudent" : "cn=ROLE_STUDENT,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", - "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", + "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", "roleAdmin" : "cn=ROLE_ADMIN,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", "roleNoSc" : "cn=ROLE_NBC_EXCLUDE,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org" }, From fdfcf51e11a2a92e23aebfb82356f3e1c868f8aa Mon Sep 17 00:00:00 2001 From: mrikallab <93978883+mrikallab@users.noreply.github.com> Date: Mon, 8 Jan 2024 14:49:47 +0100 Subject: [PATCH 16/25] N21-1421 Deactivation of ctl tools (#4672) --- ...rnal-tool-configuration-status.response.ts | 7 + ...text-external-tool-configuration-status.ts | 3 + .../mapper/tool-status-response.mapper.ts | 1 + .../service/common-tool.service.spec.ts | 73 ++++++++++ .../common/service/common-tool.service.ts | 5 + .../service/tool-version-service.spec.ts | 128 ++++++++++++++++++ .../service/tool-version-service.ts | 9 ++ .../controller/api-test/tool.api.spec.ts | 4 + .../request/external-tool-create.params.ts | 8 ++ .../request/external-tool-update.params.ts | 7 + .../dto/response/external-tool.response.ts | 4 + .../domain/external-tool.do.spec.ts | 1 + .../external-tool/domain/external-tool.do.ts | 5 + .../entity/external-tool.entity.spec.ts | 1 + .../entity/external-tool.entity.ts | 4 + .../external-tool-request.mapper.spec.ts | 12 ++ .../mapper/external-tool-request.mapper.ts | 2 + .../external-tool-response.mapper.spec.ts | 6 + .../mapper/external-tool-response.mapper.ts | 1 + ...xternal-tool-configuration.service.spec.ts | 56 ++++++++ .../external-tool-configuration.service.ts | 13 +- .../uc/dto/external-tool.types.ts | 2 + .../api-test/tool-school.api.spec.ts | 3 + ...hool-external-tool-configuration-status.ts | 3 + ...ol-external-tool-configuration.response.ts | 7 + .../dto/school-external-tool-post.params.ts | 10 +- ...l-tool-configuration-status.entity.spec.ts | 38 ++++++ ...ternal-tool-configuration-status.entity.ts | 15 ++ .../school-external-tool.entity.spec.ts | 65 +++++++-- .../entity/school-external-tool.entity.ts | 6 + ...chool-external-tool-request.mapper.spec.ts | 5 + .../school-external-tool-request.mapper.ts | 10 +- ...hool-external-tool-response.mapper.spec.ts | 3 + .../school-external-tool-response.mapper.ts | 2 +- ...ol-external-tool-status-response.mapper.ts | 1 + .../school-external-tool.service.spec.ts | 73 ++++++++++ .../service/school-external-tool.service.ts | 9 ++ .../tool-launch.controller.api.spec.ts | 109 +++++++++++++++ ...status-outdated.loggable-exception.spec.ts | 4 +- ...tool-status-outdated.loggable-exception.ts | 4 +- .../service/tool-launch.service.spec.ts | 5 +- .../service/tool-launch.service.ts | 9 +- .../external-tool.repo.integration.spec.ts | 1 + .../externaltool/external-tool.repo.mapper.ts | 2 + .../school-external-tool.repo.ts | 2 + ...l-configuration-status-response.factory.ts | 1 + .../tool/external-tool.factory.ts | 1 + ...ernal-tool-configuration-status.factory.ts | 1 + .../tool/tool-configuration-status.factory.ts | 1 + .../factory/external-tool-entity.factory.ts | 1 + ...ool-configuration-status-entity.factory.ts | 10 ++ .../school-external-tool-entity.factory.ts | 2 + ...l-configuration-status-response.factory.ts | 1 + backup/setup/context-external-tools.json | 44 ++++++ backup/setup/external-tools.json | 111 +++++++++++++++ backup/setup/school-external-tools.json | 52 +++++++ 56 files changed, 935 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts create mode 100644 apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts create mode 100644 apps/server/src/shared/testing/factory/school-external-tool-configuration-status-entity.factory.ts diff --git a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts index ca64669c166..66b0b2ecf4f 100644 --- a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts +++ b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts @@ -15,8 +15,15 @@ export class ContextExternalToolConfigurationStatusResponse { }) isOutdatedOnScopeContext: boolean; + @ApiProperty({ + type: Boolean, + description: 'Is the tool deactivated, because of superhero or school administrator', + }) + isDeactivated: boolean; + constructor(props: ContextExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts index be533e50212..ac66651841a 100644 --- a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts @@ -3,8 +3,11 @@ export class ContextExternalToolConfigurationStatus { isOutdatedOnScopeContext: boolean; + isDeactivated: boolean; + constructor(props: ContextExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts index 7e75fdd81ae..3ba0b6d9328 100644 --- a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts @@ -7,6 +7,7 @@ export class ToolStatusResponseMapper { new ContextExternalToolConfigurationStatusResponse({ isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, isOutdatedOnScopeContext: status.isOutdatedOnScopeContext, + isDeactivated: status.isDeactivated, }); return configurationStatus; diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts index cabd791a766..a47489f0892 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts @@ -86,6 +86,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }) ); }); @@ -117,6 +118,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }) ); }); @@ -210,6 +212,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: false, }) ); }); @@ -241,6 +244,76 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: false, + }) + ); + }); + }); + + describe('when schoolExternalTool is deactivated', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolVersion: 2, + status: { isDeactivated: true }, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return a configuration status with deactivated true', () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const result: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(result).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isDeactivated: true, + }) + ); + }); + }); + + describe('when externalTool is deactivated', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1, isDeactivated: true }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolVersion: 2, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return a configuration status with deactivated true', () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const result: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(result).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isDeactivated: true, }) ); }); diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index b1c8d0e8da2..9b5404f7ae7 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -20,6 +20,7 @@ export class CommonToolService { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }); if ( @@ -34,6 +35,10 @@ export class CommonToolService { configurationStatus.isOutdatedOnScopeSchool = true; } + if (externalTool.isDeactivated || schoolExternalTool.status?.isDeactivated) { + configurationStatus.isDeactivated = true; + } + return configurationStatus; } diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts index 7edeca8459e..f55651f738b 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts @@ -5,6 +5,7 @@ import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory, + schoolToolConfigurationStatusFactory, toolConfigurationStatusFactory, } from '@shared/testing'; import { ContextExternalToolConfigurationStatus } from '../../common/domain'; @@ -319,5 +320,132 @@ describe('ToolVersionService', () => { expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); }); }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and SchoolExternalTool is deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + schoolExternalTool.status = schoolToolConfigurationStatusFactory.build({ isDeactivated: true }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return status is deactivated', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and externalTool is deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + isDeactivated: true, + }); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return deactivated tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true, externalTool and schoolExternalTool are not deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({}); + + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return deactivated tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: false, + }) + ); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts index 191e1d0cc77..afe8110a88a 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts @@ -28,6 +28,7 @@ export class ToolVersionService { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: this.isToolDeactivated(externalTool, schoolExternalTool), }); try { @@ -52,4 +53,12 @@ export class ToolVersionService { return status; } + + private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool) { + if (externalTool.isDeactivated || (schoolExternalTool.status && schoolExternalTool.status.isDeactivated)) { + return true; + } + + return false; + } } 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 93da1d956b2..e41be01e880 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 @@ -87,6 +87,7 @@ describe('ToolController (API)', () => { 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, @@ -151,6 +152,7 @@ describe('ToolController (API)', () => { 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, @@ -382,6 +384,7 @@ describe('ToolController (API)', () => { 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, @@ -449,6 +452,7 @@ describe('ToolController (API)', () => { 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, diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts index 780535f3626..b3e54a14d9b 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts @@ -58,6 +58,14 @@ export class ExternalToolCreateParams { @ApiProperty() isHidden!: boolean; + @IsBoolean() + @ApiProperty({ + type: Boolean, + default: false, + description: 'Tool can be deactivated, related tools can not be added to e.g. school, course or board anymore', + }) + isDeactivated!: boolean; + @IsBoolean() @ApiProperty() openNewTab!: boolean; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts index be19e9c0bde..a3b642f496e 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts @@ -62,6 +62,13 @@ export class ExternalToolUpdateParams { @ApiProperty() isHidden!: boolean; + @IsBoolean() + @ApiProperty({ + type: Boolean, + default: false, + }) + isDeactivated!: boolean; + @IsBoolean() @ApiProperty() openNewTab!: boolean; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts index dc20b85b520..b77d170a876 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts @@ -25,6 +25,9 @@ export class ExternalToolResponse { @ApiProperty() isHidden: boolean; + @ApiProperty() + isDeactivated: boolean; + @ApiProperty() openNewTab: boolean; @@ -42,6 +45,7 @@ export class ExternalToolResponse { this.config = response.config; this.parameters = response.parameters; this.isHidden = response.isHidden; + this.isDeactivated = response.isDeactivated; this.openNewTab = response.openNewTab; this.version = response.version; this.restrictToContexts = response.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts index 22c77510b3d..b47fef01f6a 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts @@ -60,6 +60,7 @@ describe('ExternalTool', () => { isHidden: false, openNewTab: false, config: basicToolConfigFactory.build(), + isDeactivated: false, }); }).toThrowError(); }); diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index 9460fad9bc8..4bb48e21ab1 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -22,6 +22,8 @@ export interface ExternalToolProps { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; @@ -44,6 +46,8 @@ export class ExternalTool extends BaseDO implements ToolVersion { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; @@ -68,6 +72,7 @@ export class ExternalTool extends BaseDO implements ToolVersion { } this.parameters = props.parameters; this.isHidden = props.isHidden; + this.isDeactivated = props.isDeactivated; this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts index 9d037046942..9b144b2c725 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts @@ -57,6 +57,7 @@ describe('ExternalToolEntity', () => { config: basicToolConfig, parameters: [customParameter], isHidden: true, + isDeactivated: false, openNewTab: true, version: 1, }); diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 8a80404fe14..bc79a891392 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -31,6 +31,9 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { @Property() isHidden: boolean; + @Property() + isDeactivated: boolean; + @Property() openNewTab: boolean; @@ -49,6 +52,7 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { this.config = props.config; this.parameters = props.parameters; this.isHidden = props.isHidden; + this.isDeactivated = props.isDeactivated; this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts index cf29d8e874c..8089b5b4f87 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts @@ -81,6 +81,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = basicConfigParams; + externalToolCreateParams.isDeactivated = true; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -109,6 +110,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: basicToolConfigDO, + isDeactivated: true, }); return { @@ -170,6 +172,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = lti11ConfigParams; + externalToolCreateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -193,6 +196,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }); return { @@ -256,6 +260,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = oauth2ConfigParams; + externalToolCreateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -279,6 +284,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }); return { @@ -326,6 +332,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = basicConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -355,6 +362,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: basicToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); @@ -419,6 +427,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = lti11ConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -443,6 +452,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); @@ -509,6 +519,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = oauth2ConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -533,6 +544,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); 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 c57e0ee457a..3ba9d04db37 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 @@ -79,6 +79,7 @@ export class ExternalToolRequestMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalToolUpdateParams.isHidden, + isDeactivated: externalToolUpdateParams.isDeactivated, openNewTab: externalToolUpdateParams.openNewTab, version, restrictToContexts: externalToolUpdateParams.restrictToContexts, @@ -106,6 +107,7 @@ export class ExternalToolRequestMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalToolCreateParams.isHidden, + isDeactivated: externalToolCreateParams.isDeactivated, openNewTab: externalToolCreateParams.openNewTab, version, restrictToContexts: externalToolCreateParams.restrictToContexts, diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts index 0a491222081..e87502a5d21 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts @@ -61,6 +61,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: basicToolConfigResponse, + isDeactivated: true, }); const basicToolConfig: BasicToolConfig = basicToolConfigFactory.build({ @@ -91,6 +92,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: basicToolConfig, + isDeactivated: true, }); return { @@ -157,6 +159,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigResponse, + isDeactivated: false, }); const customParameter: CustomParameter = customParameterFactory.build({ @@ -182,6 +185,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }); return { @@ -245,6 +249,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigResponse, + isDeactivated: false, }); const customParameter: CustomParameter = customParameterFactory.build({ @@ -269,6 +274,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }); return { 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 7be202132ef..283f49906cf 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 @@ -63,6 +63,7 @@ export class ExternalToolResponseMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalTool.isHidden, + isDeactivated: externalTool.isDeactivated, openNewTab: externalTool.openNewTab, version: externalTool.version, restrictToContexts: externalTool.restrictToContexts, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts index 6e6f7159e80..ae0722d937c 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts @@ -6,6 +6,7 @@ import { customParameterFactory, externalToolFactory, schoolExternalToolFactory, + schoolToolConfigurationStatusFactory, setupEntities, } from '@shared/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; @@ -65,9 +66,11 @@ describe('ExternalToolConfigurationService', () => { externalToolFactory.buildWithId(undefined, 'usedToolId'), externalToolFactory.buildWithId(undefined, 'unusedToolId'), ]; + const externalTools: ExternalTool[] = [ ...notHiddenTools, externalToolFactory.buildWithId({ isHidden: true }, 'hiddenToolId'), + externalToolFactory.buildWithId({ isDeactivated: true }, 'deactivatedToolId'), ]; const externalToolsPage: Page = new Page(externalTools, externalTools.length); const toolIdsInUse: EntityId[] = ['usedToolId', 'hiddenToolId']; @@ -98,6 +101,14 @@ describe('ExternalToolConfigurationService', () => { expect(result.length).toBe(notHiddenTools.length); }); + + it('should filter out deactivated tools', () => { + const { externalToolsPage, toolIdsInUse } = setup(); + + const result: ExternalTool[] = service.filterForAvailableTools(externalToolsPage, toolIdsInUse); + + expect(result.some((tool) => tool.id !== 'deactivatedToolId')).toBe(true); + }); }); }); @@ -176,9 +187,26 @@ describe('ExternalToolConfigurationService', () => { const availableSchoolExternalTools: SchoolExternalTool[] = [ schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolId }, 'usedSchoolExternalToolId'), schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolHiddenId }, 'usedSchoolExternalToolHiddenId'), ]; + availableSchoolExternalTools.forEach((tool): void => { + if (tool.id === 'deactivatedToolId') { + tool.status = schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + } + tool.status = schoolToolConfigurationStatusFactory.build({ + isDeactivated: false, + isOutdatedOnScopeSchool: false, + }); + }); + return { externalTools, availableSchoolExternalTools }; }; @@ -192,6 +220,34 @@ describe('ExternalToolConfigurationService', () => { expect(result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.externalTool.isHidden)).toBe(true); }); + + it('should filter out deactivated external tools', () => { + const { externalTools, availableSchoolExternalTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForAvailableExternalTools( + externalTools, + availableSchoolExternalTools + ); + + expect(result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.externalTool.isDeactivated)).toBe( + true + ); + }); + + it('should filter out deactivated school external tools', () => { + const { externalTools, availableSchoolExternalTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForAvailableExternalTools( + externalTools, + availableSchoolExternalTools + ); + + expect( + result.every( + (toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.schoolExternalTool.status?.isDeactivated + ) + ).toBe(true); + }); }); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts index da852d195c0..8fd21257815 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts @@ -20,9 +20,9 @@ export class ExternalToolConfigurationService { public filterForAvailableTools(externalTools: Page, toolIdsInUse: EntityId[]): ExternalTool[] { const visibleTools: ExternalTool[] = externalTools.data.filter((tool: ExternalTool): boolean => !tool.isHidden); - const availableTools: ExternalTool[] = visibleTools.filter( - (tool: ExternalTool): boolean => !!tool.id && !toolIdsInUse.includes(tool.id) - ); + const availableTools: ExternalTool[] = visibleTools + .filter((tool: ExternalTool): boolean => !!tool.id && !toolIdsInUse.includes(tool.id)) + .filter((tool) => !tool.isDeactivated); return availableTools; } @@ -72,9 +72,10 @@ export class ExternalToolConfigurationService { const unusedTools: ContextExternalToolTemplateInfo[] = toolsWithSchoolTool.filter( (toolRef): toolRef is ContextExternalToolTemplateInfo => !!toolRef ); - const availableTools: ContextExternalToolTemplateInfo[] = unusedTools.filter( - (toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden - ); + const availableTools: ContextExternalToolTemplateInfo[] = unusedTools + .filter((toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden) + .filter((toolRef) => !toolRef.externalTool.isDeactivated) + .filter((toolRef) => !toolRef.schoolExternalTool.status?.isDeactivated); return availableTools; } diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts index 707546ba55e..a2daafe5f69 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts @@ -31,6 +31,8 @@ export type ExternalToolDto = { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 8f7f13c1d13..04c9d0d7b5d 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -96,6 +96,7 @@ describe('ToolSchoolController (API)', () => { { name: 'param1', value: 'value' }, { name: 'param2', value: '' }, ], + isDeactivated: false, }; em.persist([ @@ -458,6 +459,7 @@ describe('ToolSchoolController (API)', () => { schoolId: school.id, version: 1, parameters: [paramEntry], + isDeactivated: false, }; const updatedParamEntry: CustomParameterEntryParam = { name: 'param1', value: 'updatedValue' }; @@ -466,6 +468,7 @@ describe('ToolSchoolController (API)', () => { schoolId: school.id, version: 1, parameters: [updatedParamEntry], + isDeactivated: false, }; const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ diff --git a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts b/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts index 8e86a8894e2..b8dcfcd13d3 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts @@ -1,7 +1,10 @@ export class SchoolExternalToolConfigurationStatus { isOutdatedOnScopeSchool: boolean; + isDeactivated: boolean; + constructor(props: SchoolExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts index b8bfe811f83..36d500ba88e 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts @@ -8,7 +8,14 @@ export class SchoolExternalToolConfigurationStatusResponse { }) isOutdatedOnScopeSchool: boolean; + @ApiProperty({ + type: Boolean, + description: 'Is the tool deactivated, because of school administrator?', + }) + isDeactivated: boolean; + constructor(props: SchoolExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts index e65b1df0dd5..e21be39714b 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsMongoId, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsMongoId, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import { CustomParameterEntryParam } from './custom-parameter-entry.params'; export class SchoolExternalToolPostParams { @@ -21,6 +21,14 @@ export class SchoolExternalToolPostParams { @Type(() => CustomParameterEntryParam) parameters?: CustomParameterEntryParam[]; + @ApiProperty({ + type: Boolean, + default: false, + description: 'Tool can be deactivated, related tools can not be added to e.g. course or board anymore', + }) + @IsBoolean() + isDeactivated!: boolean; + @ApiProperty() @IsNumber() version!: number; diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts new file mode 100644 index 00000000000..ded662d34a6 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts @@ -0,0 +1,38 @@ +import { setupEntities } from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; +import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; + +describe('SchoolExternalToolConfigurationStatusEntity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new SchoolExternalToolConfigurationStatusEntity(); + expect(test).toThrow(); + }); + + it('should create a school external tool configuration status by passing required properties', () => { + const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = + schoolExternalToolConfigurationStatusEntityFactory.build(); + expect( + schoolExternalToolConfigurationStatusEntity instanceof SchoolExternalToolConfigurationStatusEntity + ).toEqual(false); + }); + + it('should set school external tool status', () => { + const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = + new SchoolExternalToolConfigurationStatusEntity({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + + expect(schoolExternalToolConfigurationStatusEntity).toEqual({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts new file mode 100644 index 00000000000..ea071f996e1 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts @@ -0,0 +1,15 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class SchoolExternalToolConfigurationStatusEntity { + @Property() + isOutdatedOnScopeSchool: boolean; + + @Property() + isDeactivated: boolean; + + constructor(props: SchoolExternalToolConfigurationStatusEntity) { + this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts index c3b03b2d0cd..932ec713f54 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts @@ -1,11 +1,13 @@ -import { schoolFactory, setupEntities } from '@shared/testing'; -import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; import { - BasicToolConfigEntity, - CustomParameterEntity, - ExternalToolEntity, - ExternalToolConfigEntity, -} from '../../external-tool/entity'; + basicToolConfigFactory, + customParameterEntityFactory, + externalToolEntityFactory, + schoolFactory, + setupEntities, +} from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; +import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; +import { CustomParameterEntity, ExternalToolEntity, ExternalToolConfigEntity } from '../../external-tool/entity'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; import { SchoolExternalToolEntity } from './school-external-tool.entity'; @@ -27,11 +29,11 @@ describe('SchoolExternalToolEntity', () => { }); it('should set schoolParameters to empty when is undefined', () => { - const externalToolConfigEntity: ExternalToolConfigEntity = new BasicToolConfigEntity({ + const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ type: ToolConfigType.OAUTH2, baseUrl: 'mockBaseUrl', }); - const customParameter: CustomParameterEntity = new CustomParameterEntity({ + const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ name: 'parameterName', displayName: 'User Friendly Name', default: 'mock', @@ -43,7 +45,7 @@ describe('SchoolExternalToolEntity', () => { isOptional: false, isProtected: false, }); - const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ name: 'toolName', url: 'mockUrl', logoUrl: 'mockLogoUrl', @@ -52,15 +54,56 @@ describe('SchoolExternalToolEntity', () => { isHidden: true, openNewTab: true, version: 1, + isDeactivated: false, }); - const schoolExternalToolEntity: SchoolExternalToolEntity = new SchoolExternalToolEntity({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school: schoolFactory.buildWithId(), schoolParameters: [], toolVersion: 1, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), }); expect(schoolExternalToolEntity.schoolParameters).toEqual([]); }); + + it('should set school external tool configuration status', () => { + const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ + type: ToolConfigType.OAUTH2, + baseUrl: 'mockBaseUrl', + }); + const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ + name: 'parameterName', + displayName: 'User Friendly Name', + default: 'mock', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + isProtected: false, + }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + name: 'toolName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + config: externalToolConfigEntity, + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + isDeactivated: false, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school: schoolFactory.buildWithId(), + schoolParameters: [], + toolVersion: 1, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), + }); + + expect(schoolExternalToolEntity.status).toEqual({ isDeactivated: false, isOutdatedOnScopeSchool: false }); + }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts index b5545239042..2662a5d3986 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -3,12 +3,14 @@ import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { CustomParameterEntryEntity } from '../../common/entity'; import { ExternalToolEntity } from '../../external-tool/entity'; +import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; export interface SchoolExternalToolProperties { tool: ExternalToolEntity; school: SchoolEntity; schoolParameters?: CustomParameterEntryEntity[]; toolVersion: number; + status?: SchoolExternalToolConfigurationStatusEntity; } @Entity({ tableName: 'school-external-tools' }) @@ -25,11 +27,15 @@ export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { @Property() toolVersion: number; + @Embedded(() => SchoolExternalToolConfigurationStatusEntity, { object: true, nullable: true }) + status?: SchoolExternalToolConfigurationStatusEntity; + constructor(props: SchoolExternalToolProperties) { super(); this.tool = props.tool; this.school = props.school; this.schoolParameters = props.schoolParameters ?? []; this.toolVersion = props.toolVersion; + this.status = props.status; } } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts index 002f2c571cc..dbbc9717c04 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts @@ -1,3 +1,4 @@ +import { schoolToolConfigurationStatusFactory } from '@shared/testing'; import { SchoolExternalToolRequestMapper } from './school-external-tool-request.mapper'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; @@ -17,6 +18,7 @@ describe('SchoolExternalToolRequestMapper', () => { version: 1, schoolId: 'schoolId', parameters: [param], + isDeactivated: true, }; return { @@ -35,6 +37,7 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: [{ name: param.name, value: param.value }], schoolId: params.schoolId, toolVersion: params.version, + status: schoolToolConfigurationStatusFactory.build({ isDeactivated: true }), }); }); }); @@ -46,6 +49,7 @@ describe('SchoolExternalToolRequestMapper', () => { version: 1, schoolId: 'schoolId', parameters: undefined, + isDeactivated: false, }; return { @@ -63,6 +67,7 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: [], schoolId: params.schoolId, toolVersion: params.version, + status: schoolToolConfigurationStatusFactory.build(), }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts index eff05c092cb..3fb0da2ee5b 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { CustomParameterEntry } from '../../common/domain'; -import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; +import { + CustomParameterEntryParam, + SchoolExternalToolConfigurationStatus, + SchoolExternalToolPostParams, +} from '../controller/dto'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; @Injectable() @@ -11,6 +15,10 @@ export class SchoolExternalToolRequestMapper { schoolId: request.schoolId, toolVersion: request.version, parameters: this.mapRequestToCustomParameterEntryDO(request.parameters ?? []), + status: new SchoolExternalToolConfigurationStatus({ + isOutdatedOnScopeSchool: false, + isDeactivated: request.isDeactivated, + }), }; } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts index eb2e0da2fe5..73580d89633 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts @@ -58,6 +58,7 @@ describe('SchoolExternalToolResponseMapper', () => { ], status: schoolToolConfigurationStatusResponseFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }, { @@ -74,6 +75,7 @@ describe('SchoolExternalToolResponseMapper', () => { ], status: schoolToolConfigurationStatusFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }, ]) @@ -108,6 +110,7 @@ describe('SchoolExternalToolResponseMapper', () => { name: '', status: schoolToolConfigurationStatusResponseFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }) ); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts index 7f48f554003..d0b984a77b8 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts @@ -26,7 +26,7 @@ export class SchoolExternalToolResponseMapper { parameters: this.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), toolVersion: schoolExternalTool.toolVersion, status: SchoolToolConfigurationStatusResponseMapper.mapToResponse( - schoolExternalTool.status ?? { isOutdatedOnScopeSchool: false } + schoolExternalTool.status ?? { isOutdatedOnScopeSchool: false, isDeactivated: false } ), }; } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts index 290ad5c084a..001efe071c0 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts @@ -6,6 +6,7 @@ export class SchoolToolConfigurationStatusResponseMapper { const configurationStatus: SchoolExternalToolConfigurationStatusResponse = new SchoolExternalToolConfigurationStatusResponse({ isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, + isDeactivated: status.isDeactivated, }); return configurationStatus; 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 43875b3d55c..ad0183f4536 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 @@ -191,6 +191,19 @@ describe('SchoolExternalToolService', () => { }) ); }); + + it('should return non deactivated tool status', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: false, + }) + ); + }); }); describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and validation throws error', () => { @@ -221,6 +234,66 @@ describe('SchoolExternalToolService', () => { ); }); }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and schoolExternalTool is deactivated', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + status: schoolToolConfigurationStatusFactory.build({ isDeactivated: true }), + }); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); + toolFearures.toolStatusWithoutVersions = true; + + return { + schoolExternalTool, + }; + }; + + it('should return deactivated tool status true', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and externalTool is deactivated', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ isDeactivated: true }); + + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); + toolFearures.toolStatusWithoutVersions = true; + + return { + schoolExternalTool, + }; + }; + + it('should return deactivated tool status true', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: true, + }) + ); + }); + }); }); }); 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 b396147e72c..0d58bcf00d0 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 @@ -59,6 +59,7 @@ export class SchoolExternalToolService { ): Promise { const status: SchoolExternalToolConfigurationStatus = new SchoolExternalToolConfigurationStatus({ isOutdatedOnScopeSchool: true, + isDeactivated: this.isToolDeactivated(externalTool, tool), }); if (this.toolFeatures.toolStatusWithoutVersions) { @@ -91,4 +92,12 @@ export class SchoolExternalToolService { createdSchoolExternalTool = await this.enrichDataFromExternalTool(createdSchoolExternalTool); return createdSchoolExternalTool; } + + private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool) { + if (externalTool.isDeactivated || schoolExternalTool.status?.isDeactivated) { + return true; + } + + return false; + } } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index aa35059b564..d313cae5d50 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -16,6 +16,7 @@ import { schoolFactory, customParameterFactory, } from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; import { Response } from 'supertest'; import { CustomParameterLocation, CustomParameterScope, ToolConfigType } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; @@ -174,6 +175,114 @@ describe('ToolLaunchController (API)', () => { }); }); + describe('when user wants to launch a deactivated tool', () => { + describe('when external tool is deactivated', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + version: 1, + isDeactivated: true, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + toolVersion: 0, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + toolVersion: 0, + }); + + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; + }; + + it('should return a bad request', async () => { + const { params, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when school external tool is deactivated', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + version: 1, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + toolVersion: 0, + status: schoolExternalToolConfigurationStatusEntityFactory.build({ + isDeactivated: true, + }), + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + toolVersion: 0, + }); + + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; + }; + + it('should return a bad request', async () => { + const { params, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + describe('when user wants to launch tool from another school', () => { const setup = async () => { const toolSchool: SchoolEntity = schoolFactory.buildWithId(); diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts index 168d32e1f12..814dfad394c 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts @@ -12,7 +12,8 @@ describe('ToolStatusOutdatedLoggableException', () => { userId, toolId, toolConfigStatus.isOutdatedOnScopeSchool, - toolConfigStatus.isOutdatedOnScopeContext + toolConfigStatus.isOutdatedOnScopeContext, + toolConfigStatus.isDeactivated ); return { @@ -34,6 +35,7 @@ describe('ToolStatusOutdatedLoggableException', () => { toolId: 'toolId', isOutdatedOnScopeSchool: false, isOutdatedOnScopeContext: false, + isDeactivated: false, }, }); }); diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts index 59a4fea3766..84b358e2ec6 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts @@ -7,7 +7,8 @@ export class ToolStatusOutdatedLoggableException extends BadRequestException imp private readonly userId: EntityId, private readonly toolId: EntityId, private readonly isOutdatedOnScopeSchool: boolean, - private readonly isOutdatedOnScopeContext: boolean + private readonly isOutdatedOnScopeContext: boolean, + private readonly isDeactivated: boolean ) { super(); } @@ -22,6 +23,7 @@ export class ToolStatusOutdatedLoggableException extends BadRequestException imp toolId: this.toolId, isOutdatedOnScopeSchool: this.isOutdatedOnScopeSchool, isOutdatedOnScopeContext: this.isOutdatedOnScopeContext, + isDeactivated: this.isDeactivated, }, }; } diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 8bfae71cce1..30ce3c20cbf 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -200,7 +200,7 @@ describe('ToolLaunchService', () => { }); }); - describe('when tool configuration status is not LATEST', () => { + describe('when tool configuration status is not launchable', () => { const setup = () => { const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const contextExternalTool: ContextExternalTool = contextExternalToolFactory @@ -233,6 +233,7 @@ describe('ToolLaunchService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: true, }) ); @@ -249,7 +250,7 @@ describe('ToolLaunchService', () => { const func = () => service.getLaunchData(userId, launchParams.contextExternalTool); await expect(func).rejects.toThrow( - new ToolStatusOutdatedLoggableException(userId, contextExternalToolId, true, true) + new ToolStatusOutdatedLoggableException(userId, contextExternalToolId, true, true, true) ); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index bb323017967..8378926a107 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -54,7 +54,7 @@ export class ToolLaunchService { const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - await this.isToolStatusLatestOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); + await this.isToolStatusLaunchableOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); const strategy: ToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); @@ -84,7 +84,7 @@ export class ToolLaunchService { }; } - private async isToolStatusLatestOrThrow( + private async isToolStatusLaunchableOrThrow( userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, @@ -97,12 +97,13 @@ export class ToolLaunchService { contextExternalTool ); - if (status.isOutdatedOnScopeSchool || status.isOutdatedOnScopeContext) { + if (status.isOutdatedOnScopeSchool || status.isOutdatedOnScopeContext || status.isDeactivated) { throw new ToolStatusOutdatedLoggableException( userId, contextExternalTool.id ?? '', status.isOutdatedOnScopeSchool, - status.isOutdatedOnScopeContext + status.isOutdatedOnScopeContext, + status.isDeactivated ); } } diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index e3e2949d5c1..a17320075d6 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -158,6 +158,7 @@ describe('ExternalToolRepo', () => { isHidden: true, openNewTab: true, version: 2, + isDeactivated: false, }); return { diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index 7740647ea9f..b9113606c7e 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -40,6 +40,7 @@ export class ExternalToolRepoMapper { config, parameters: this.mapCustomParametersToDOs(entity.parameters || []), isHidden: entity.isHidden, + isDeactivated: entity.isDeactivated, openNewTab: entity.openNewTab, version: entity.version, restrictToContexts: entity.restrictToContexts, @@ -100,6 +101,7 @@ export class ExternalToolRepoMapper { config, parameters: this.mapCustomParameterDOsToEntities(entityDO.parameters ?? []), isHidden: entityDO.isHidden, + isDeactivated: entityDO.isDeactivated, openNewTab: entityDO.openNewTab, version: entityDO.version, restrictToContexts: entityDO.restrictToContexts, 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 e910856caef..cfe2a61f940 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 @@ -71,6 +71,7 @@ export class SchoolExternalToolRepo extends BaseDORepo(() => { return { isOutdatedOnScopeSchool: false, + isDeactivated: false, }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts index e9d6e4f25d4..75458a32094 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts @@ -5,5 +5,6 @@ export const toolConfigurationStatusFactory = Factory.define(() => { + return { + isOutdatedOnScopeSchool: false, + isDeactivated: false, + }; + }); diff --git a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts index ff8e62fa4bc..e3f8b45cd59 100644 --- a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts @@ -1,6 +1,7 @@ import { SchoolExternalToolEntity, SchoolExternalToolProperties } from '@modules/tool/school-external-tool/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; import { externalToolEntityFactory } from './external-tool-entity.factory'; +import { schoolExternalToolConfigurationStatusEntityFactory } from './school-external-tool-configuration-status-entity.factory'; import { schoolFactory } from './school.factory'; export const schoolExternalToolEntityFactory = BaseFactory.define< @@ -12,5 +13,6 @@ export const schoolExternalToolEntityFactory = BaseFactory.define< school: schoolFactory.buildWithId(), schoolParameters: [{ name: 'schoolMockParameter', value: 'mockValue' }], toolVersion: 0, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), }; }); diff --git a/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts b/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts index 6dabc2148c0..480b68a181a 100644 --- a/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts +++ b/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts @@ -5,5 +5,6 @@ export const schoolToolConfigurationStatusResponseFactory = Factory.define(() => { return { isOutdatedOnScopeSchool: false, + isDeactivated: false, }; }); diff --git a/backup/setup/context-external-tools.json b/backup/setup/context-external-tools.json index 239539c14e9..46fd0272a4d 100644 --- a/backup/setup/context-external-tools.json +++ b/backup/setup/context-external-tools.json @@ -97,6 +97,50 @@ "value": "test" }], "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de3cfab79fd5bd57e68fe" + }, + "createdAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "schoolTool": { + "$oid": "647de374cf6a427b9d39e6be" + }, + "contextId": "5fa3a2f3a9c31a26f4d1d309", + "contextType": "course", + "displayName": "Cypress Test Tool deactivated on External Tool", + "parameters": [{ + "name": "contextparam", + "value": "test" + }], + "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de3cfab79fd5bd57e68ff" + }, + "createdAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "schoolTool": { + "$oid": "647de374cf6a427b9d39e7be" + }, + "contextId": "5fa3a2f3a9c31a26f4d1d309", + "contextType": "course", + "displayName": "Cypress Test Tool deactivated on School External Tool", + "parameters": [{ + "name": "contextparam", + "value": "test" + }], + "toolVersion": 1 } ] diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 87764bd724d..f10aecc35a0 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -216,6 +216,117 @@ "isHidden": false, "openNewTab": false, "version": 2 + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e6c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool deactivated External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": true + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e7c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool deactivated School External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": false + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e8c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool active External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": false } ] diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 6c39fd86c0a..380cf2d46b8 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -154,5 +154,57 @@ "value": "test" }], "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de374cf6a427b9d39e6be" + }, + "createdAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "tool": { + "$oid": "647de247cf6a427b9d39e6c3" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [{ + "name": "schoolParan", + "value": "test" + }], + "toolVersion": 1, + "status": { + "isDeactivated": false, + "isOutdatedOnScopeSchool": false + } + }, + { + "_id": { + "$oid": "647de374cf6a427b9d39e7be" + }, + "createdAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "tool": { + "$oid": "647de247cf6a427b9d39e7c3" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [{ + "name": "schoolParan", + "value": "test" + }], + "toolVersion": 1, + "status": { + "isDeactivated": true, + "isOutdatedOnScopeSchool": false + } } ] From e609d31ca1286269d81d8514236dd94755cbd9e9 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:59:30 +0100 Subject: [PATCH 17/25] BC-6041 - catch errors from imagemagick (#4675) --- .../interface/error-status.enum.ts | 3 + .../loggable/preview-exception.spec.ts | 40 +++++ .../loggable/preview-exception.ts | 27 +++ .../preview-generator.service.spec.ts | 161 ++++++++++++++---- .../preview-generator.service.ts | 70 ++++++-- 5 files changed, 247 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/infra/preview-generator/interface/error-status.enum.ts create mode 100644 apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts create mode 100644 apps/server/src/infra/preview-generator/loggable/preview-exception.ts diff --git a/apps/server/src/infra/preview-generator/interface/error-status.enum.ts b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts new file mode 100644 index 00000000000..0f018ba720f --- /dev/null +++ b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts @@ -0,0 +1,3 @@ +export enum ErrorType { + CREATE_PREVIEW_NOT_POSSIBLE = 'CREATE_PREVIEW_NOT_POSSIBLE', +} diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts new file mode 100644 index 00000000000..708607b3556 --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts @@ -0,0 +1,40 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { PreviewNotPossibleException } from './preview-exception'; + +describe(PreviewNotPossibleException.name, () => { + describe('WHEN getLogMessage is called', () => { + const setup = () => { + const payload = { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + previewOptions: { + format: 'format', + width: 100, + }, + }; + const error = new Error('error'); + + return { payload, error }; + }; + + it('should return error log message', () => { + const { payload, error } = setup(); + + const exception = new PreviewNotPossibleException(payload, error); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: InternalServerErrorException.name, + stack: exception.stack, + error, + data: { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + format: 'format', + width: 100, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts new file mode 100644 index 00000000000..fcfd1c023dc --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts @@ -0,0 +1,27 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { PreviewFileOptions } from '../interface'; +import { ErrorType } from '../interface/error-status.enum'; + +export class PreviewNotPossibleException extends InternalServerErrorException implements Loggable { + constructor(private readonly payload: PreviewFileOptions, private readonly error?: Error) { + super(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + } + + getLogMessage(): ErrorLogMessage { + const { originFilePath, previewFilePath, previewOptions } = this.payload; + const message: ErrorLogMessage = { + type: InternalServerErrorException.name, + stack: this.stack, + error: this.error, + data: { + originFilePath, + previewFilePath, + format: previewOptions.format, + width: previewOptions.width, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts index 203e4aa9b56..eb8344e22f2 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts @@ -1,12 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { GetFile, S3ClientAdapter } from '@infra/s3-client'; -import { UnprocessableEntityException } from '@nestjs/common'; +import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@src/core/logger'; -import { Readable } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; +import { ErrorType } from './interface/error-status.enum'; import { PreviewGeneratorService } from './preview-generator.service'; -const streamMock = jest.fn(); +let streamMock = jest.fn(); const resizeMock = jest.fn(); const coalesceMock = jest.fn(); const selectFrameMock = jest.fn(); @@ -16,7 +18,7 @@ const imageMagickMock = () => { resize: resizeMock, selectFrame: selectFrameMock, coalesce: coalesceMock, - data: Readable.from('text'), + data: Buffer.from('text'), }; }; jest.mock('gm', () => { @@ -40,6 +42,21 @@ const createFile = (contentRange?: string, contentType?: string): GetFile => { return fileResponse; }; +const createMockStream = (err: Error | null = null) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + streamMock = jest + .fn() + .mockImplementation( + (_format: string, callback: (err: Error | null, stdout: PassThrough, stderr: PassThrough) => void) => { + callback(err, stdout, stderr); + } + ); + + return { stdout, stderr }; +}; + describe('PreviewGeneratorService', () => { let module: TestingModule; let service: PreviewGeneratorService; @@ -92,15 +109,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call storageClient get method with originFilePath', async () => { @@ -125,12 +141,18 @@ describe('PreviewGeneratorService', () => { await service.generatePreview(params); - expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format); + expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format, expect.any(Function)); expect(streamMock).toHaveBeenCalledTimes(1); }); it('should call S3ClientAdapters create method', async () => { - const { params, expectedFileData } = setup(); + const { params } = setup(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedFileData = expect.objectContaining({ + data: expect.any(PassThrough), + mimeType: params.previewOptions.format, + }); await service.generatePreview(params); @@ -161,15 +183,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'application/pdf'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks selectFrameMock method', async () => { @@ -195,15 +216,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/gif'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks coalesce method', async () => { @@ -237,7 +257,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup(); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -246,7 +266,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup('text/plain'); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -266,15 +286,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should not call imagemagicks resize method', async () => { @@ -286,5 +305,75 @@ describe('PreviewGeneratorService', () => { expect(resizeMock).not.toHaveBeenCalledTimes(1); }); }); + + describe('WHEN STDERR stream has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data1 = Buffer.from('imagemagick '); + const data2 = Buffer.from('is not found'); + const { stderr } = createMockStream(); + + process.nextTick(() => { + stderr.write(data1); + stderr.write(data2); + stderr.end(); + }); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + + it('should have external error in getLogMessage', async () => { + const { params } = setup(); + try { + await service.generatePreview(params); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(error.getLogMessage().error).toEqual(new Error('imagemagick is not found')); + } + }); + }); + + describe('WHEN GM library has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + createMockStream(new Error('imagemagic is not found')); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + }); }); }); diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.ts b/apps/server/src/infra/preview-generator/preview-generator.service.ts index 5fd9fc8fb5e..d16cc5c8e0d 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.ts @@ -1,10 +1,11 @@ import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { subClass } from 'gm'; +import m, { subClass } from 'gm'; import { PassThrough } from 'stream'; import { PreviewFileOptions, PreviewInputMimeTypes, PreviewOptions, PreviewResponseMessage } from './interface'; import { PreviewActionsLoggable } from './loggable/preview-actions.loggable'; +import { PreviewNotPossibleException } from './loggable/preview-exception'; import { PreviewGeneratorBuilder } from './preview-generator.builder'; @Injectable() @@ -16,25 +17,28 @@ export class PreviewGeneratorService { } public async generatePreview(params: PreviewFileOptions): Promise { - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); - const { originFilePath, previewFilePath, previewOptions } = params; + try { + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); + const { originFilePath, previewFilePath, previewOptions } = params; - const original = await this.downloadOriginFile(originFilePath); + const original = await this.downloadOriginFile(originFilePath); - this.checkIfPreviewPossible(original, params); + this.checkIfPreviewPossible(original, params); - const preview = this.resizeAndConvert(original, previewOptions); + const preview = await this.resizeAndConvert(original, previewOptions); + const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); - const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); + await this.storageClient.create(previewFilePath, file); - await this.storageClient.create(previewFilePath, file); + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - - return { - previewFilePath, - status: true, - }; + return { + previewFilePath, + status: true, + }; + } catch (error) { + throw new PreviewNotPossibleException(params, error as Error); + } } private checkIfPreviewPossible(original: GetFile, params: PreviewFileOptions): void | UnprocessableEntityException { @@ -43,7 +47,7 @@ export class PreviewGeneratorService { if (!isPreviewPossible) { this.logger.warning(new PreviewActionsLoggable('PreviewGeneratorService.previewNotPossible', params)); - throw new UnprocessableEntityException(); + throw new UnprocessableEntityException('Unsupported file type for preview generation'); } } @@ -53,7 +57,7 @@ export class PreviewGeneratorService { return file; } - private resizeAndConvert(original: GetFile, previewParams: PreviewOptions): PassThrough { + private async resizeAndConvert(original: GetFile, previewParams: PreviewOptions): Promise { const { format, width } = previewParams; const preview = this.imageMagick(original.data); @@ -70,8 +74,38 @@ export class PreviewGeneratorService { preview.resize(width, undefined, '>'); } - const result = preview.stream(format); + return this.convert(preview, format); + } - return result; + private convert(preview: m.State, format: string): Promise { + const promise = new Promise((resolve, reject) => { + preview.stream(format, (err, stdout, stderr) => { + if (err) { + reject(err); + } + + const throughStream = new PassThrough(); + stdout.pipe(throughStream); + stdout.on('data', () => { + resolve(throughStream); + }); + + const errorChunks: Array = []; + stderr.on('data', (chunk: Uint8Array) => { + errorChunks.push(chunk); + }); + + stderr.on('end', () => { + let errorMessage = ''; + Buffer.concat(errorChunks).forEach((chunk) => { + errorMessage += String.fromCharCode(chunk); + }); + + reject(new Error(errorMessage)); + }); + }); + }); + + return promise; } } From f335c98b275da791f94b418e3227e2564148b1dd Mon Sep 17 00:00:00 2001 From: wiaderwek Date: Tue, 9 Jan 2024 11:50:25 +0100 Subject: [PATCH 18/25] add tldraw application level metrics (#4663) * add tldraw application level metrics --------- Co-authored-by: Tomasz Wiaderek --- .../src/modules/tldraw/metrics/index.ts | 1 + .../modules/tldraw/metrics/metrics.service.ts | 40 +++++++++++++++++++ .../tldraw/service/tldraw.ws.service.spec.ts | 3 +- .../tldraw/service/tldraw.ws.service.ts | 8 +++- .../src/modules/tldraw/tldraw-test.module.ts | 3 +- .../modules/tldraw/tldraw-ws-test.module.ts | 3 +- .../src/modules/tldraw/tldraw-ws.module.ts | 3 +- 7 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/modules/tldraw/metrics/index.ts create mode 100644 apps/server/src/modules/tldraw/metrics/metrics.service.ts diff --git a/apps/server/src/modules/tldraw/metrics/index.ts b/apps/server/src/modules/tldraw/metrics/index.ts new file mode 100644 index 00000000000..70337867b90 --- /dev/null +++ b/apps/server/src/modules/tldraw/metrics/index.ts @@ -0,0 +1 @@ +export * from './metrics.service'; diff --git a/apps/server/src/modules/tldraw/metrics/metrics.service.ts b/apps/server/src/modules/tldraw/metrics/metrics.service.ts new file mode 100644 index 00000000000..ace2899c36b --- /dev/null +++ b/apps/server/src/modules/tldraw/metrics/metrics.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { Gauge, register } from 'prom-client'; + +@Injectable() +export class MetricsService { + private numberOfUsersOnServerCounter: Gauge; + + private numberOfBoardsOnServerCounter: Gauge; + + constructor() { + this.numberOfUsersOnServerCounter = new Gauge({ + name: 'sc_tldraw_users', + help: 'Number of active users per pod', + }); + + this.numberOfBoardsOnServerCounter = new Gauge({ + name: 'sc_tldraw_boards', + help: 'Number of active boards per pod', + }); + + register.registerMetric(this.numberOfUsersOnServerCounter); + register.registerMetric(this.numberOfBoardsOnServerCounter); + } + + public incrementNumberOfUsersOnServerCounter(): void { + this.numberOfUsersOnServerCounter.inc(); + } + + public decrementNumberOfUsersOnServerCounter(): void { + this.numberOfUsersOnServerCounter.dec(); + } + + public incrementNumberOfBoardsOnServerCounter(): void { + this.numberOfBoardsOnServerCounter.inc(); + } + + public decrementNumberOfBoardsOnServerCounter(): void { + this.numberOfBoardsOnServerCounter.dec(); + } +} diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts index ddd186fed0a..1199bf217cc 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -10,6 +10,7 @@ import * as SyncProtocols from 'y-protocols/sync'; import * as AwarenessProtocol from 'y-protocols/awareness'; import { encoding } from 'lib0'; import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; +import { MetricsService } from '@modules/tldraw/metrics'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; import { config } from '../config'; import { TldrawBoardRepo } from '../repo'; @@ -51,7 +52,7 @@ describe('TldrawWSService', () => { const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ imports, - providers: [TldrawWs, TldrawBoardRepo, TldrawWsService], + providers: [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService], }).compile(); service = testingModule.get(TldrawWsService); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts index 660f5258fa8..ff455454d8b 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -8,6 +8,7 @@ import { Persitence, WSConnectionState, WSMessageType } from '../types'; import { TldrawConfig } from '../config'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; import { TldrawBoardRepo } from '../repo'; +import { MetricsService } from '../metrics'; @Injectable() export class TldrawWsService { @@ -19,7 +20,8 @@ export class TldrawWsService { constructor( private readonly configService: ConfigService, - private readonly tldrawBoardRepo: TldrawBoardRepo + private readonly tldrawBoardRepo: TldrawBoardRepo, + private readonly metricsService: MetricsService ) { this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); } @@ -47,7 +49,9 @@ export class TldrawWsService { }) .catch(() => {}); this.docs.delete(doc.name); + this.metricsService.decrementNumberOfBoardsOnServerCounter(); } + this.metricsService.decrementNumberOfUsersOnServerCounter(); } try { @@ -106,6 +110,7 @@ export class TldrawWsService { this.persistence.bindState(docName, doc).catch(() => {}); } this.docs.set(docName, doc); + this.metricsService.incrementNumberOfBoardsOnServerCounter(); return doc; }); } @@ -197,6 +202,7 @@ export class TldrawWsService { this.send(doc, ws, encoding.toUint8Array(encoder)); } } + this.metricsService.incrementNumberOfUsersOnServerCounter(); } public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts index 49f8bd5c820..3e3cd60396e 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -5,6 +5,7 @@ import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; import { Course, User } from '@shared/domain/entity'; +import { MetricsService } from '@modules/tldraw/metrics'; import { AuthenticationApiModule } from '../authentication/authentication-api.module'; import { TldrawWsModule } from './tldraw-ws.module'; import { TldrawWs } from './controller'; @@ -20,7 +21,7 @@ const imports = [ CoreModule, LoggerModule, ]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; @Module({ imports, providers, diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts index 6e3c5a58479..815f09cbccd 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -3,13 +3,14 @@ import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/da import { CoreModule } from '@src/core'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; +import { MetricsService } from '@modules/tldraw/metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { config } from './config'; import { TldrawWs } from './controller'; const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; @Module({ imports, providers, diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 98e91b5b3e6..183c579296f 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; +import { MetricsService } from '@modules/tldraw/metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { TldrawWs } from './controller'; @@ -10,6 +11,6 @@ import { config } from './config'; @Module({ imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], - providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo], + providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo, MetricsService], }) export class TldrawWsModule {} From e6193d34327a209cbe7887ce55fed18546749d2c Mon Sep 17 00:00:00 2001 From: sszafGCA <116172610+sszafGCA@users.noreply.github.com> Date: Tue, 9 Jan 2024 12:06:45 +0100 Subject: [PATCH 19/25] BC-5832- Add deleting creator ID reference from the files entities (the legacy files, rewritten from FeathersJS) in the main user deletion use case (#4662) --- .../deletion/uc/deletion-request.uc.spec.ts | 8 +- .../deletion/uc/deletion-request.uc.ts | 4 +- .../modules/files/entity/file.entity.spec.ts | 44 ++++++++ .../src/modules/files/entity/file.entity.ts | 16 ++- .../src/modules/files/repo/files.repo.spec.ts | 53 +++++++-- .../src/modules/files/repo/files.repo.ts | 23 +++- .../files/service/files.service.spec.ts | 104 +++++++++++------- .../modules/files/service/files.service.ts | 13 ++- 8 files changed, 200 insertions(+), 65 deletions(-) diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index cd7f0a00443..9ebee1e77ef 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -218,7 +218,7 @@ describe(DeletionRequestUc.name, () => { courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); - filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles.mockResolvedValueOnce(2); lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); pseudonymService.deleteByUserId.mockResolvedValueOnce(2); teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); @@ -336,7 +336,9 @@ describe(DeletionRequestUc.name, () => { await uc.executeDeletionRequests(); - expect(filesService.removeUserPermissionsToAnyFiles).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + expect(filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles).toHaveBeenCalledWith( + deletionRequestToExecute.targetRefId + ); }); it('should call filesStorageClientAdapterService.removeCreatorIdFromFileRecords to remove cratorId to any files in fileRecords module', async () => { @@ -482,7 +484,7 @@ describe(DeletionRequestUc.name, () => { courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); - filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles.mockResolvedValueOnce(2); lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); pseudonymService.deleteByUserId.mockResolvedValueOnce(2); teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index 97d2284a4cc..16973af5a2c 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -204,14 +204,14 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUsersFilesAndPermissions', deletionRequest }); const filesDeleted: number = await this.filesService.markFilesOwnedByUserForDeletion(deletionRequest.targetRefId); - const filePermissionsUpdated: number = await this.filesService.removeUserPermissionsToAnyFiles( + const filesUpdated: number = await this.filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles( deletionRequest.targetRefId ); await this.logDeletion( deletionRequest, DomainModel.FILE, DeletionOperationModel.UPDATE, - filesDeleted + filePermissionsUpdated, + filesDeleted + filesUpdated, 0 ); } diff --git a/apps/server/src/modules/files/entity/file.entity.spec.ts b/apps/server/src/modules/files/entity/file.entity.spec.ts index ea9649f4c66..bf52769f32b 100644 --- a/apps/server/src/modules/files/entity/file.entity.spec.ts +++ b/apps/server/src/modules/files/entity/file.entity.spec.ts @@ -94,6 +94,50 @@ describe(FileEntity.name, () => { }); }); + describe('removCreatorId', () => { + describe('when called on a file that contains matching creatorId', () => { + const setup = () => { + const file = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + }); + + const expectedFile = copyFile(file); + expectedFile._creatorId = undefined; + + return { file, expectedFile }; + }; + it('should properly remove this creatorId', () => { + const { file, expectedFile } = setup(); + + file.removeCreatorId(mainUserId); + + expect(file).toEqual(expectedFile); + }); + }); + + describe("when called on a file that doesn't have any permission with given refId", () => { + const setup = () => { + const file = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + }); + + const originalFile = copyFile(file); + + const randomUserId = new ObjectId().toHexString(); + return { file, originalFile, randomUserId }; + }; + it('should not modify the file in any way (including the other present permissions)', () => { + const { file, originalFile, randomUserId } = setup(); + + file.removeCreatorId(randomUserId); + + expect(file).toEqual(originalFile); + }); + }); + }); + describe('markForDeletion', () => { describe('when called on some typical file', () => { it('should properly mark the file for deletion', () => { diff --git a/apps/server/src/modules/files/entity/file.entity.ts b/apps/server/src/modules/files/entity/file.entity.ts index 68b667fe7c3..335e0f4d4b1 100644 --- a/apps/server/src/modules/files/entity/file.entity.ts +++ b/apps/server/src/modules/files/entity/file.entity.ts @@ -27,7 +27,7 @@ export interface FileEntityProps { parentId?: EntityId; ownerId: EntityId; refOwnerModel: FileOwnerModel; - creatorId: EntityId; + creatorId?: EntityId; permissions: FilePermissionEntity[]; lockId?: EntityId; versionKey?: number; @@ -95,12 +95,12 @@ export class FileEntity extends BaseEntityWithTimestamps { @Enum({ nullable: false }) refOwnerModel: FileOwnerModel; - @Property({ fieldName: 'creator' }) + @Property({ fieldName: 'creator', nullable: true }) @Index() - _creatorId: ObjectId; + _creatorId?: ObjectId; - get creatorId(): EntityId { - return this._creatorId.toHexString(); + get creatorId(): EntityId | undefined { + return this._creatorId?.toHexString(); } @Embedded(() => FilePermissionEntity, { array: true, nullable: false }) @@ -141,6 +141,12 @@ export class FileEntity extends BaseEntityWithTimestamps { return this.deleted && this.deletedAt !== undefined && !Number.isNaN(this.deletedAt.getTime()); } + public removeCreatorId(creatorId: EntityId): void { + if (creatorId === this._creatorId?.toHexString()) { + this._creatorId = undefined; + } + } + constructor(props: FileEntityProps) { super(); diff --git a/apps/server/src/modules/files/repo/files.repo.spec.ts b/apps/server/src/modules/files/repo/files.repo.spec.ts index d96fb27cf04..b6654384698 100644 --- a/apps/server/src/modules/files/repo/files.repo.spec.ts +++ b/apps/server/src/modules/files/repo/files.repo.spec.ts @@ -228,13 +228,19 @@ describe(FilesRepo.name, () => { }); }); - describe('findByPermissionRefId', () => { - describe('when searching for a files to which the user with given userId has access', () => { + describe('findByPermissionRefIdOrCreatorId', () => { + describe('when searching for a files to which the user with given userId has access or is creator', () => { const setup = async () => { const mainUserId = new ObjectId().toHexString(); const otherUserId = new ObjectId().toHexString(); // Test files created, owned and accessible only by the other user. + const otherUserFileWithMainUserCreator = fileEntityFactory.build({ + ownerId: otherUserId, + creatorId: mainUserId, + permissions: [filePermissionEntityFactory.build({ refId: otherUserId })], + }); + const otherUserFile = fileEntityFactory.build({ ownerId: otherUserId, creatorId: otherUserId, @@ -268,9 +274,35 @@ describe(FilesRepo.name, () => { permissions: [filePermissionEntityFactory.build({ refId: mainUserId })], }); - await em.persistAndFlush([otherUserFile, mainUserSharedFile, otherUserSharedFile, mainUserFile]); + await em.persistAndFlush([ + otherUserFileWithMainUserCreator, + mainUserSharedFile, + otherUserSharedFile, + mainUserFile, + otherUserFile, + ]); em.clear(); + const expectedOtherUserFileWithMainUserCreatorProps = { + id: otherUserFileWithMainUserCreator.id, + createdAt: otherUserFileWithMainUserCreator.createdAt, + updatedAt: otherUserFileWithMainUserCreator.updatedAt, + deleted: false, + isDirectory: false, + name: otherUserFileWithMainUserCreator.name, + size: otherUserFileWithMainUserCreator.size, + type: otherUserFileWithMainUserCreator.type, + storageFileName: otherUserFileWithMainUserCreator.storageFileName, + bucket: otherUserFileWithMainUserCreator.bucket, + thumbnail: otherUserFileWithMainUserCreator.thumbnail, + thumbnailRequestToken: otherUserFileWithMainUserCreator.thumbnailRequestToken, + securityCheck: otherUserFileWithMainUserCreator.securityCheck, + shareTokens: [], + refOwnerModel: otherUserFileWithMainUserCreator.refOwnerModel, + permissions: otherUserFileWithMainUserCreator.permissions, + versionKey: 0, + }; + const expectedMainUserSharedFileProps = { id: mainUserSharedFile.id, createdAt: mainUserSharedFile.createdAt, @@ -336,6 +368,8 @@ describe(FilesRepo.name, () => { mainUserSharedFile, otherUserSharedFile, mainUserFile, + otherUserFileWithMainUserCreator, + expectedOtherUserFileWithMainUserCreatorProps, expectedMainUserSharedFileProps, expectedOtherUserSharedFileProps, expectedMainUserFileProps, @@ -349,18 +383,21 @@ describe(FilesRepo.name, () => { mainUserSharedFile, otherUserSharedFile, mainUserFile, + otherUserFileWithMainUserCreator, expectedMainUserSharedFileProps, expectedOtherUserSharedFileProps, expectedMainUserFileProps, + expectedOtherUserFileWithMainUserCreatorProps, } = await setup(); - const results = await repo.findByPermissionRefId(mainUserId); + const results = await repo.findByPermissionRefIdOrCreatorId(mainUserId); - expect(results).toHaveLength(3); + expect(results).toHaveLength(4); // Verify explicit fields. expect(results).toEqual( expect.arrayContaining([ + expect.objectContaining(expectedOtherUserFileWithMainUserCreatorProps), expect.objectContaining(expectedMainUserSharedFileProps), expect.objectContaining(expectedOtherUserSharedFileProps), expect.objectContaining(expectedMainUserFileProps), @@ -370,6 +407,7 @@ describe(FilesRepo.name, () => { // Verify storage provider id. expect(results.map((result) => result.storageProvider?.id)).toEqual( expect.arrayContaining([ + otherUserFileWithMainUserCreator.storageProvider?.id, mainUserSharedFile.storageProvider?.id, otherUserSharedFile.storageProvider?.id, mainUserFile.storageProvider?.id, @@ -384,6 +422,7 @@ describe(FilesRepo.name, () => { // Verify implicit creatorId field. expect(results.map((result) => result.creatorId)).toEqual( expect.arrayContaining([ + otherUserFileWithMainUserCreator.creatorId, mainUserSharedFile.creatorId, otherUserSharedFile.creatorId, mainUserFile.creatorId, @@ -397,7 +436,7 @@ describe(FilesRepo.name, () => { await em.persistAndFlush([fileEntityFactory.build(), fileEntityFactory.build(), fileEntityFactory.build()]); em.clear(); - const results = await repo.findByPermissionRefId(new ObjectId().toHexString()); + const results = await repo.findByPermissionRefIdOrCreatorId(new ObjectId().toHexString()); expect(results).toHaveLength(0); }); @@ -407,7 +446,7 @@ describe(FilesRepo.name, () => { it('should return an empty array', async () => { const testPermissionRefId = new ObjectId().toHexString(); - const results = await repo.findByPermissionRefId(testPermissionRefId); + const results = await repo.findByPermissionRefIdOrCreatorId(testPermissionRefId); expect(results).toHaveLength(0); }); diff --git a/apps/server/src/modules/files/repo/files.repo.ts b/apps/server/src/modules/files/repo/files.repo.ts index 4b30386d6d4..87cf26ec28e 100644 --- a/apps/server/src/modules/files/repo/files.repo.ts +++ b/apps/server/src/modules/files/repo/files.repo.ts @@ -41,15 +41,28 @@ export class FilesRepo extends BaseRepo { return files as FileEntity[]; } - public async findByPermissionRefId(permissionRefId: EntityId): Promise { + public async findByPermissionRefIdOrCreatorId(userId: EntityId): Promise { + const refId = new ObjectId(userId); + const pipeline = [ { $match: { - permissions: { - $elemMatch: { - refId: new ObjectId(permissionRefId), + $and: [ + { + $or: [ + { + permissions: { + $elemMatch: { + refId, + }, + }, + }, + { creator: refId }, + ], }, - }, + { deleted: false }, + { deletedAt: undefined }, + ], }, }, ]; diff --git a/apps/server/src/modules/files/service/files.service.spec.ts b/apps/server/src/modules/files/service/files.service.spec.ts index 98ddc788d34..4a65adaefc6 100644 --- a/apps/server/src/modules/files/service/files.service.spec.ts +++ b/apps/server/src/modules/files/service/files.service.spec.ts @@ -30,7 +30,7 @@ describe(FilesService.name, () => { }); afterEach(() => { - repo.findByPermissionRefId.mockClear(); + repo.findByPermissionRefIdOrCreatorId.mockClear(); repo.findByOwnerUserId.mockClear(); repo.save.mockClear(); }); @@ -39,82 +39,105 @@ describe(FilesService.name, () => { await module.close(); }); - describe('findFilesAccessibleByUser', () => { + describe('findFilesAccessibleOrCreatedByUser', () => { describe('when called with a userId of a user that', () => { const setup = () => { const userId = new ObjectId().toHexString(); - const accessibleFiles: FileEntity[] = []; + const userId2 = new ObjectId().toHexString(); + const userId3 = new ObjectId().toHexString(); + const accessibleOrCreatedFiles: FileEntity[] = []; for (let i = 0; i < 5; i += 1) { - accessibleFiles.push( + accessibleOrCreatedFiles.push( fileEntityFactory.build({ permissions: [filePermissionEntityFactory.build({ refId: userId })], + }), + fileEntityFactory.build({ + creatorId: userId2, + }), + fileEntityFactory.build({ + permissions: [filePermissionEntityFactory.build({ refId: userId })], + creatorId: userId3, + }), + fileEntityFactory.build({ + permissions: [filePermissionEntityFactory.build({ refId: userId3 })], + creatorId: userId, }) ); } - return { userId, accessibleFiles }; + return { userId, accessibleOrCreatedFiles }; }; - describe("doesn't have an access to any files", () => { + describe("doesn't have an access or is creator of any files", () => { it('should return an empty array', async () => { const { userId } = setup(); - repo.findByPermissionRefId.mockResolvedValueOnce([]); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([]); - const result = await service.findFilesAccessibleByUser(userId); + const result = await service.findFilesAccessibleOrCreatedByUser(userId); - expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); expect(result).toEqual([]); }); }); - describe('does have an access to some files', () => { + describe('does have an access or is creator of some files', () => { it('should return an array containing proper file entities', async () => { - const { userId, accessibleFiles } = setup(); + const { userId, accessibleOrCreatedFiles } = setup(); - repo.findByPermissionRefId.mockResolvedValueOnce(accessibleFiles); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce(accessibleOrCreatedFiles); - const result = await service.findFilesAccessibleByUser(userId); + const result = await service.findFilesAccessibleOrCreatedByUser(userId); - expect(repo.findByPermissionRefId).toBeCalledWith(userId); - expect(result).toEqual(accessibleFiles); + expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); + expect(result).toEqual(accessibleOrCreatedFiles); }); }); }); }); - describe('removeUserPermissionsToAnyFiles', () => { - it('should not modify any files if there are none that user has permission to access', async () => { + describe('removeUserPermissionsOrCreatorReferenceToAnyFiles', () => { + it('should not modify any files if there are none that user has permission to access or is creator', async () => { const userId = new ObjectId().toHexString(); - repo.findByPermissionRefId.mockResolvedValueOnce([]); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([]); - const result = await service.removeUserPermissionsToAnyFiles(userId); + const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); expect(result).toEqual(0); - expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); expect(repo.save).not.toBeCalled(); }); - describe('should properly remove user permissions', () => { - it('in case of just a single file accessible by given user', async () => { + describe('should properly remove user permissions, creatorId reference', () => { + const setup = () => { const userId = new ObjectId().toHexString(); const userPermission = filePermissionEntityFactory.build({ refId: userId }); - const entity = fileEntityFactory.buildWithId({ permissions: [userPermission] }); - repo.findByPermissionRefId.mockResolvedValueOnce([entity]); + const entity = fileEntityFactory.buildWithId({ permissions: [userPermission], creatorId: userId }); + const entity2 = fileEntityFactory.buildWithId({ creatorId: userId }); + const entity3 = fileEntityFactory.buildWithId({ permissions: [userPermission] }); - const result = await service.removeUserPermissionsToAnyFiles(userId); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([entity, entity2, entity3]); + return { userId, userPermission, entity, entity2, entity3 }; + }; + it('in case of just a single file (permission) accessible by given user and couple of files created', async () => { + const { userId, userPermission, entity, entity2, entity3 } = setup(); - expect(result).toEqual(1); - expect(entity.permissions).not.toContain(userPermission); + const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); - expect(repo.findByPermissionRefId).toBeCalledWith(userId); - expect(repo.save).toBeCalledWith([entity]); + expect(result).toEqual(3); + expect(entity3.permissions).not.toContain(userPermission); + expect(entity._creatorId).toBe(undefined); + expect(entity3.permissions).not.toContain(userPermission); + expect(entity2._creatorId).toBe(undefined); + + expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); + expect(repo.save).toBeCalledWith([entity, entity2, entity3]); }); - it('in case of many files accessible by given user', async () => { + it('in case of many files accessible or created by given user', async () => { const userId = new ObjectId().toHexString(); const userPermission = filePermissionEntityFactory.build({ refId: userId }); const anotherUserPermission = filePermissionEntityFactory.build(); @@ -125,31 +148,36 @@ describe(FilesService.name, () => { }), fileEntityFactory.buildWithId({ permissions: [yetAnotherUserPermission, userPermission, anotherUserPermission], + creatorId: userId, }), fileEntityFactory.buildWithId({ permissions: [anotherUserPermission, yetAnotherUserPermission, userPermission], }), fileEntityFactory.buildWithId({ permissions: [userPermission, yetAnotherUserPermission, anotherUserPermission], + creatorId: userId, }), fileEntityFactory.buildWithId({ permissions: [yetAnotherUserPermission, anotherUserPermission, userPermission], }), ]; - repo.findByPermissionRefId.mockResolvedValueOnce(entities); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce(entities); - const result = await service.removeUserPermissionsToAnyFiles(userId); + const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); expect(result).toEqual(5); - entities.forEach((entity) => { - expect(entity.permissions).not.toContain(userPermission); - expect(entity.permissions).toContain(anotherUserPermission); - expect(entity.permissions).toContain(yetAnotherUserPermission); - }); + for (let i = 0; i < entities.length; i += 1) { + expect(entities[i].permissions).not.toContain(userPermission); + if (i === 1 || i === 3) { + expect(entities[i]._creatorId).toBe(undefined); + } + expect(entities[i].permissions).toContain(anotherUserPermission); + expect(entities[i].permissions).toContain(yetAnotherUserPermission); + } - expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); expect(repo.save).toBeCalledWith(entities); }); }); diff --git a/apps/server/src/modules/files/service/files.service.ts b/apps/server/src/modules/files/service/files.service.ts index 7f7666edbc3..c665e196f46 100644 --- a/apps/server/src/modules/files/service/files.service.ts +++ b/apps/server/src/modules/files/service/files.service.ts @@ -7,18 +7,21 @@ import { FilesRepo } from '../repo'; export class FilesService { constructor(private readonly repo: FilesRepo) {} - async findFilesAccessibleByUser(userId: EntityId): Promise { - return this.repo.findByPermissionRefId(userId); + async findFilesAccessibleOrCreatedByUser(userId: EntityId): Promise { + return this.repo.findByPermissionRefIdOrCreatorId(userId); } - async removeUserPermissionsToAnyFiles(userId: EntityId): Promise { - const entities = await this.repo.findByPermissionRefId(userId); + async removeUserPermissionsOrCreatorReferenceToAnyFiles(userId: EntityId): Promise { + const entities = await this.repo.findByPermissionRefIdOrCreatorId(userId); if (entities.length === 0) { return 0; } - entities.forEach((entity) => entity.removePermissionsByRefId(userId)); + entities.forEach((entity) => { + entity.removePermissionsByRefId(userId); + entity.removeCreatorId(userId); + }); await this.repo.save(entities); From dc301b5c297651959a1c19167da0d3e94b091222 Mon Sep 17 00:00:00 2001 From: Phillip Date: Tue, 9 Jan 2024 15:32:01 +0100 Subject: [PATCH 20/25] BC-6200 removing suspicious code (#4684) in a find context there is no specific id thus resolving to undefined and resulting in an feathers mongoose issue --- src/services/lesson/hooks/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/lesson/hooks/index.js b/src/services/lesson/hooks/index.js index a5923ce154f..bcd8b2db2ce 100644 --- a/src/services/lesson/hooks/index.js +++ b/src/services/lesson/hooks/index.js @@ -249,7 +249,7 @@ exports.before = () => { hasPermission('TOPIC_VIEW'), iff(isProvider('external'), validateLessonFind), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons), ], get: [ hasPermission('TOPIC_VIEW'), From 534455409a59d106b8fa26bc623262585abf8a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Tue, 9 Jan 2024 15:52:43 +0100 Subject: [PATCH 21/25] N21-1497 Delete groups that were deselected from provisioning (#4683) --- .../modules/group/entity/group-user.entity.ts | 3 +- .../src/modules/group/repo/group.repo.spec.ts | 112 +++++++++- .../src/modules/group/repo/group.repo.ts | 23 ++ .../modules/group/repo/group.scope.spec.ts | 26 +++ .../src/modules/group/repo/group.scope.ts | 7 + .../group/service/group.service.spec.ts | 42 ++++ .../modules/group/service/group.service.ts | 14 ++ .../domain/base-provisioning-options.ts | 5 +- .../src/modules/legacy-school/domain/index.ts | 1 + .../domain/provisioning-options-type.ts | 3 + .../school-system-options.builder.spec.ts | 43 ++-- .../domain/school-system-options.builder.ts | 15 +- .../schulconnex-provisionin-options.do.ts | 5 + .../legacy-school/legacy-school.module.ts | 15 +- .../modules/legacy-school/loggable/index.ts | 1 + ...tegy-no-options.loggable-exception.spec.ts | 21 ++ ...-strategy-no-options.loggable-exception.ts | 20 ++ .../modules/legacy-school/service/index.ts | 2 + .../provisioning-options-update-handler.ts | 6 + ...rovisioning-options-update.service.spec.ts | 66 ++++++ .../provisioning-options-update.service.ts | 29 +++ ...rovisioning-options-update.service.spec.ts | 209 ++++++++++++++++++ ...nex-provisioning-options-update.service.ts | 45 ++++ .../uc/school-system-options.uc.spec.ts | 86 ++++++- .../uc/school-system-options.uc.ts | 23 +- .../config/provisioning-config.ts | 2 - .../strategy/iserv/iserv.strategy.spec.ts | 10 +- .../strategy/iserv/iserv.strategy.ts | 15 +- .../strategy/oidc/oidc.strategy.spec.ts | 2 - .../strategy/oidc/oidc.strategy.ts | 4 +- ...ser-login-migration-revert.service.spec.ts | 4 +- .../modules/user/service/user.service.spec.ts | 8 +- .../src/modules/user/service/user.service.ts | 4 +- .../user/user-do.repo.integration.spec.ts | 45 ++++ .../src/shared/repo/user/user-do.repo.ts | 11 + backup/setup/schools.json | 4 +- config/default.schema.json | 5 - src/services/config/publicAppConfigService.js | 1 - 38 files changed, 858 insertions(+), 79 deletions(-) create mode 100644 apps/server/src/modules/legacy-school/domain/provisioning-options-type.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts create mode 100644 apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts create mode 100644 apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts create mode 100644 apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts create mode 100644 apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts create mode 100644 apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts diff --git a/apps/server/src/modules/group/entity/group-user.entity.ts b/apps/server/src/modules/group/entity/group-user.entity.ts index e202de7a400..d69ef492b25 100644 --- a/apps/server/src/modules/group/entity/group-user.entity.ts +++ b/apps/server/src/modules/group/entity/group-user.entity.ts @@ -1,5 +1,6 @@ import { Embeddable, ManyToOne } from '@mikro-orm/core'; -import { Role, User } from '@shared/domain/entity'; +import { Role } from '@shared/domain/entity/role.entity'; +import { User } from '@shared/domain/entity/user.entity'; export interface GroupUserEntityProps { user: User; diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index a8e39f9d86e..e127155fd25 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -2,13 +2,14 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { ExternalSource, UserDO } from '@shared/domain/domainobject'; -import { SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { cleanupCollections, groupEntityFactory, groupFactory, roleFactory, schoolFactory, + systemEntityFactory, userDoFactory, userFactory, } from '@shared/testing'; @@ -268,6 +269,115 @@ describe('GroupRepo', () => { }); }); + describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { + describe('when groups for the school exist', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + type: GroupEntityTypes.CLASS, + organization: school, + externalSource: { + system, + }, + }); + groups[1].type = GroupEntityTypes.COURSE; + groups[2].type = GroupEntityTypes.OTHER; + + const otherSchool: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + await em.persistAndFlush([school, system, ...groups, otherSchool, ...otherGroups]); + em.clear(); + + return { + school, + system, + otherSchool, + groups, + }; + }; + + it('should return the groups', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toHaveLength(1); + }); + + it('should only return groups from the selected school', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result.every((group) => group.organizationId === school.id)).toEqual(true); + }); + + it('should only return groups from the selected system', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result.every((group) => group.externalSource?.systemId === system.id)).toEqual(true); + }); + + it('should return only groups of the given group type', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + }); + }); + + describe('when no group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([school, system]); + em.clear(); + + return { + school, + system, + }; + }; + + it('should return an empty array', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('save', () => { describe('when a new object is provided', () => { const setup = () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index d355ee6b117..768a6f3f733 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -83,6 +83,29 @@ export class GroupRepo { return domainObjects; } + public async findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId: EntityId, + systemId: EntityId, + groupType: GroupTypes + ): Promise { + const groupEntityType: GroupEntityTypes = GroupTypesToGroupEntityTypesMapping[groupType]; + + const scope: Scope = new GroupScope() + .byOrganizationId(schoolId) + .bySystemId(systemId) + .byTypes([groupEntityType]); + + const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); + + const domainObjects: Group[] = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + public async save(domainObject: Group): Promise { const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); diff --git a/apps/server/src/modules/group/repo/group.scope.spec.ts b/apps/server/src/modules/group/repo/group.scope.spec.ts index 1088651853b..28a71540e70 100644 --- a/apps/server/src/modules/group/repo/group.scope.spec.ts +++ b/apps/server/src/modules/group/repo/group.scope.spec.ts @@ -54,6 +54,32 @@ describe(GroupScope.name, () => { }); }); + describe('bySystemId', () => { + describe('when id is undefined', () => { + it('should not add query', () => { + scope.bySystemId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('when id is defined', () => { + const setup = () => { + return { + id: new ObjectId().toHexString(), + }; + }; + + it('should add query', () => { + const { id } = setup(); + + scope.bySystemId(id); + + expect(scope.query).toEqual({ externalSource: { system: id } }); + }); + }); + }); + describe('byUserId', () => { describe('when id is undefined', () => { it('should not add query', () => { diff --git a/apps/server/src/modules/group/repo/group.scope.ts b/apps/server/src/modules/group/repo/group.scope.ts index be0c6938aa5..e7e0a5f7b3d 100644 --- a/apps/server/src/modules/group/repo/group.scope.ts +++ b/apps/server/src/modules/group/repo/group.scope.ts @@ -18,6 +18,13 @@ export class GroupScope extends Scope { return this; } + bySystemId(id: EntityId | undefined): this { + if (id) { + this.addQuery({ externalSource: { system: id } }); + } + return this; + } + byUserId(id: EntityId | undefined): this { if (id) { this.addQuery({ users: { user: new ObjectId(id) } }); diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 51986e983d0..3acc7dab5ca 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -217,6 +217,48 @@ describe('GroupService', () => { }); }); + describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { + describe('when the school has groups of type class', () => { + const setup = () => { + const schoolId: string = new ObjectId().toHexString(); + const systemId: string = new ObjectId().toHexString(); + const groups: Group[] = groupFactory.buildList(3); + + groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValue(groups); + + return { + schoolId, + systemId, + groups, + }; + }; + + it('should search for the groups', async () => { + const { schoolId, systemId } = setup(); + + await service.findGroupsBySchoolIdAndSystemIdAndGroupType(schoolId, systemId, GroupTypes.CLASS); + + expect(groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolId, + systemId, + GroupTypes.CLASS + ); + }); + + it('should return the groups', async () => { + const { schoolId, systemId, groups } = setup(); + + const result: Group[] = await service.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + GroupTypes.CLASS + ); + + expect(result).toEqual(groups); + }); + }); + }); + describe('save', () => { describe('when saving a group', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 5ac511f55b2..5dfd41256bb 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -44,6 +44,20 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } + public async findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId: EntityId, + systemId: EntityId, + groupType: GroupTypes + ): Promise { + const group: Group[] = await this.groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + groupType + ); + + return group; + } + public async save(group: Group): Promise { const savedGroup: Group = await this.groupRepo.save(group); diff --git a/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts b/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts index 3676ce72ee2..80c059c79f8 100644 --- a/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts +++ b/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts @@ -1,4 +1,5 @@ import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningOptionsType } from './provisioning-options-type'; export abstract class BaseProvisioningOptions { public isApplicable(provisioningOptions: ProvisioningOptionsInterface): provisioningOptions is T { @@ -11,5 +12,7 @@ export abstract class BaseProvisioningOptions { + describe('getDefaultProvisioningOptions', () => { + describe('when the provisioning strategy has options', () => { + it('should have the correct options instance', () => { + const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder(SystemProvisioningStrategy.SANIS); + + const result: AnyProvisioningOptions = builder.getDefaultProvisioningOptions(); + + expect(result).toBeInstanceOf(SchulConneXProvisioningOptions); + }); + }); + + describe('when the provisioning strategy has no options', () => { + it('should throw an error', () => { + const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( + SystemProvisioningStrategy.UNDEFINED + ); + + expect(() => builder.getDefaultProvisioningOptions()).toThrow(ProvisioningStrategyNoOptionsLoggableException); + }); + }); + }); + describe('buildProvisioningOptions', () => { describe('when the provisioning strategy is "SANIS" and the options are valid', () => { const setup = () => { @@ -52,21 +77,5 @@ describe(SchoolSystemOptionsBuilder.name, () => { ).toThrow(ProvisioningStrategyInvalidOptionsLoggableException); }); }); - - describe('when the provisioning strategy has no options', () => { - it('should throw an error', () => { - const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( - SystemProvisioningStrategy.UNDEFINED - ); - - expect(() => - builder.buildProvisioningOptions({ - groupProvisioningClassesEnabled: true, - groupProvisioningCoursesEnabled: true, - groupProvisioningOtherEnabled: true, - }) - ).toThrow(ProvisioningStrategyInvalidOptionsLoggableException); - }); - }); }); }); diff --git a/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts index f431b27f5ae..8af03d85e3a 100644 --- a/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts +++ b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts @@ -1,22 +1,31 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { ProvisioningOptionsInterface } from '../interface'; -import { ProvisioningStrategyInvalidOptionsLoggableException } from '../loggable'; +import { + ProvisioningStrategyInvalidOptionsLoggableException, + ProvisioningStrategyNoOptionsLoggableException, +} from '../loggable'; import { provisioningStrategyOptions } from './provisioning-strategy-options'; import { AnyProvisioningOptions } from './school-system-options.do'; export class SchoolSystemOptionsBuilder { constructor(private readonly provisioningStrategy: SystemProvisioningStrategy) {} - public buildProvisioningOptions(provisioningOptions: ProvisioningOptionsInterface): AnyProvisioningOptions { + public getDefaultProvisioningOptions(): AnyProvisioningOptions { const ProvisioningOptionsConstructor: (new () => AnyProvisioningOptions) | undefined = provisioningStrategyOptions.get(this.provisioningStrategy); if (!ProvisioningOptionsConstructor) { - throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); + throw new ProvisioningStrategyNoOptionsLoggableException(this.provisioningStrategy); } const createdProvisioningOptions: AnyProvisioningOptions = new ProvisioningOptionsConstructor(); + return createdProvisioningOptions; + } + + public buildProvisioningOptions(provisioningOptions: ProvisioningOptionsInterface): AnyProvisioningOptions { + const createdProvisioningOptions: AnyProvisioningOptions = this.getDefaultProvisioningOptions(); + if (!createdProvisioningOptions.isApplicable(provisioningOptions)) { throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); } diff --git a/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts b/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts index 7bbcbfc48af..78f63faa6c9 100644 --- a/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts +++ b/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts @@ -1,5 +1,6 @@ import { SchulConneXProvisioningOptionsInterface } from '../interface'; import { BaseProvisioningOptions } from './base-provisioning-options'; +import { ProvisioningOptionsType } from './provisioning-options-type'; export class SchulConneXProvisioningOptions extends BaseProvisioningOptions @@ -11,6 +12,10 @@ export class SchulConneXProvisioningOptions groupProvisioningOtherEnabled = false; + get getType(): ProvisioningOptionsType { + return ProvisioningOptionsType.SCHULCONNEX; + } + set(props: SchulConneXProvisioningOptionsInterface): this { this.groupProvisioningClassesEnabled = props.groupProvisioningClassesEnabled; this.groupProvisioningCoursesEnabled = props.groupProvisioningCoursesEnabled; diff --git a/apps/server/src/modules/legacy-school/legacy-school.module.ts b/apps/server/src/modules/legacy-school/legacy-school.module.ts index 8fc5893bb42..92d028044d2 100644 --- a/apps/server/src/modules/legacy-school/legacy-school.module.ts +++ b/apps/server/src/modules/legacy-school/legacy-school.module.ts @@ -1,3 +1,4 @@ +import { GroupModule } from '@modules/group'; import { Module } from '@nestjs/common'; import { FederalStateRepo, LegacySchoolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; @@ -5,16 +6,18 @@ import { SchoolSystemOptionsRepo, SchoolYearRepo } from './repo'; import { FederalStateService, LegacySchoolService, + ProvisioningOptionsUpdateService, SchoolSystemOptionsService, SchoolValidationService, SchoolYearService, + SchulconnexProvisioningOptionsUpdateService, } from './service'; /** * @deprecated because it uses the deprecated LegacySchoolDo. */ @Module({ - imports: [LoggerModule], + imports: [LoggerModule, GroupModule], providers: [ LegacySchoolRepo, LegacySchoolService, @@ -25,7 +28,15 @@ import { SchoolValidationService, SchoolSystemOptionsRepo, SchoolSystemOptionsService, + ProvisioningOptionsUpdateService, + SchulconnexProvisioningOptionsUpdateService, + ], + exports: [ + LegacySchoolService, + SchoolYearService, + FederalStateService, + SchoolSystemOptionsService, + ProvisioningOptionsUpdateService, ], - exports: [LegacySchoolService, SchoolYearService, FederalStateService, SchoolSystemOptionsService], }) export class LegacySchoolModule {} diff --git a/apps/server/src/modules/legacy-school/loggable/index.ts b/apps/server/src/modules/legacy-school/loggable/index.ts index 5e5deb07997..8288e746eb4 100644 --- a/apps/server/src/modules/legacy-school/loggable/index.ts +++ b/apps/server/src/modules/legacy-school/loggable/index.ts @@ -2,3 +2,4 @@ export { SchoolNumberDuplicateLoggableException } from './school-number-duplicat export { ProvisioningStrategyInvalidOptionsLoggableException } from './provisioning-strategy-invalid-options.loggable-exception'; export { ProvisioningStrategyMissingLoggableException } from './provisioning-strategy-missing.loggable-exception'; export { ProvisioningOptionsInvalidTypeLoggableException } from './provisioning-options-invalid-type.loggable-exception'; +export { ProvisioningStrategyNoOptionsLoggableException } from './provisioning-strategy-no-options.loggable-exception'; diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts new file mode 100644 index 00000000000..8be361e1f0b --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts @@ -0,0 +1,21 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ProvisioningStrategyNoOptionsLoggableException } from './provisioning-strategy-no-options.loggable-exception'; + +describe(ProvisioningStrategyNoOptionsLoggableException.name, () => { + describe('getLogMessage', () => { + it('should log the correct message', () => { + const exception = new ProvisioningStrategyNoOptionsLoggableException(SystemProvisioningStrategy.SANIS); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'PROVISIONING_STRATEGY_NO_OPTIONS', + message: expect.any(String), + stack: expect.any(String), + data: { + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts new file mode 100644 index 00000000000..69a1726459c --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts @@ -0,0 +1,20 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ProvisioningStrategyNoOptionsLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly provisioningStrategy: SystemProvisioningStrategy) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PROVISIONING_STRATEGY_NO_OPTIONS', + message: 'The provisioning strategy does not support options.', + stack: this.stack, + data: { + provisioningStrategy: this.provisioningStrategy, + }, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/service/index.ts b/apps/server/src/modules/legacy-school/service/index.ts index 65307ab17f0..3df9f66017d 100644 --- a/apps/server/src/modules/legacy-school/service/index.ts +++ b/apps/server/src/modules/legacy-school/service/index.ts @@ -3,3 +3,5 @@ export * from './school-year.service'; export * from './federal-state.service'; export * from './validation'; export { SchoolSystemOptionsService } from './school-system-options.service'; +export { ProvisioningOptionsUpdateService } from './provisioning-options-update.service'; +export { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts new file mode 100644 index 00000000000..1a13b81b7dc --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts @@ -0,0 +1,6 @@ +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions } from '../domain'; + +export interface ProvisioningOptionsUpdateHandler { + handleUpdate(schoolId: EntityId, systemId: EntityId, newOptions: T, oldOptions: T): Promise; +} diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts new file mode 100644 index 00000000000..45092fbd9c3 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from '../domain'; +import { ProvisioningOptionsUpdateService } from './provisioning-options-update.service'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +describe(ProvisioningOptionsUpdateService.name, () => { + let module: TestingModule; + let service: ProvisioningOptionsUpdateService; + + let schulconnexProvisioningOptionsUpdateService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ProvisioningOptionsUpdateService, + { + provide: SchulconnexProvisioningOptionsUpdateService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ProvisioningOptionsUpdateService); + schulconnexProvisioningOptionsUpdateService = module.get(SchulconnexProvisioningOptionsUpdateService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handleUpdate', () => { + describe('when the options are of type schulconnex', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + const provisioningOptions: SchulConneXProvisioningOptions = new SchulConneXProvisioningOptions(); + + return { + schoolId, + systemId, + provisioningOptions, + }; + }; + + it('should execute the schulconnex service', async () => { + const { schoolId, systemId, provisioningOptions } = setup(); + + await service.handleUpdate(schoolId, systemId, provisioningOptions, provisioningOptions); + + expect(schulconnexProvisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolId, + systemId, + provisioningOptions, + provisioningOptions + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts new file mode 100644 index 00000000000..7bfa12ac81f --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions, ProvisioningOptionsType } from '../domain'; +import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +@Injectable() +export class ProvisioningOptionsUpdateService { + private readonly updateServices = new Map(); + + constructor( + private readonly schulconnexProvisioningOptionsUpdateService: SchulconnexProvisioningOptionsUpdateService + ) { + this.updateServices.set(ProvisioningOptionsType.SCHULCONNEX, this.schulconnexProvisioningOptionsUpdateService); + } + + public async handleUpdate( + schoolId: EntityId, + systemId: EntityId, + newOptions: AnyProvisioningOptions, + oldOptions: AnyProvisioningOptions + ): Promise { + const updater: ProvisioningOptionsUpdateHandler | undefined = this.updateServices.get(oldOptions.getType); + + if (updater) { + await updater.handleUpdate(schoolId, systemId, newOptions, oldOptions); + } + } +} diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts new file mode 100644 index 00000000000..57f2c9387a4 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts @@ -0,0 +1,209 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { Test, TestingModule } from '@nestjs/testing'; +import { groupFactory, schoolSystemOptionsFactory } from '@shared/testing'; +import { SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +describe(SchulconnexProvisioningOptionsUpdateService.name, () => { + let module: TestingModule; + let service: SchulconnexProvisioningOptionsUpdateService; + + let groupService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchulconnexProvisioningOptionsUpdateService, + { + provide: GroupService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexProvisioningOptionsUpdateService); + groupService = module.get(GroupService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handleUpdate', () => { + describe('when groupProvisioningClassesEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + const group: Group = groupFactory.build({ type: GroupTypes.CLASS }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all classes of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.CLASS + ); + }); + + it('should delete all classes', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + + describe('when groupProvisioningCoursesEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: false, + }); + const group: Group = groupFactory.build({ type: GroupTypes.COURSE }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all courses of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.COURSE + ); + }); + + it('should delete all courses', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + + describe('when groupProvisioningOtherEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: false, + groupProvisioningCoursesEnabled: true, + }); + const group: Group = groupFactory.build({ type: GroupTypes.OTHER }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all other groups of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.OTHER + ); + }); + + it('should delete all other groups', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts new file mode 100644 index 00000000000..f6494fdd951 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts @@ -0,0 +1,45 @@ +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from '../domain'; +import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; + +@Injectable() +export class SchulconnexProvisioningOptionsUpdateService + implements ProvisioningOptionsUpdateHandler +{ + constructor(private readonly groupService: GroupService) {} + + public async handleUpdate( + schoolId: EntityId, + systemId: EntityId, + newOptions: SchulConneXProvisioningOptions, + oldOptions: SchulConneXProvisioningOptions + ): Promise { + if (oldOptions.groupProvisioningClassesEnabled && !newOptions.groupProvisioningClassesEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.CLASS); + } + + if (oldOptions.groupProvisioningCoursesEnabled && !newOptions.groupProvisioningCoursesEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.COURSE); + } + + if (oldOptions.groupProvisioningOtherEnabled && !newOptions.groupProvisioningOtherEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.OTHER); + } + } + + private async deleteGroups(schoolId: EntityId, systemId: EntityId, groupType: GroupTypes): Promise { + const groups: Group[] = await this.groupService.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + groupType + ); + + await Promise.all( + groups.map(async (group: Group): Promise => { + await this.groupService.delete(group); + }) + ); + } +} diff --git a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts index 21312bc9988..c235b96f77b 100644 --- a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts @@ -7,9 +7,9 @@ import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Permission } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { schoolSystemOptionsFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; -import { AnyProvisioningOptions, SchoolSystemOptions } from '../domain'; +import { AnyProvisioningOptions, SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; import { ProvisioningStrategyMissingLoggableException } from '../loggable'; -import { SchoolSystemOptionsService } from '../service'; +import { ProvisioningOptionsUpdateService, SchoolSystemOptionsService } from '../service'; import { SchoolSystemOptionsUc } from './school-system-options.uc'; describe(SchoolSystemOptionsUc.name, () => { @@ -19,6 +19,7 @@ describe(SchoolSystemOptionsUc.name, () => { let authorizationService: DeepMocked; let systemService: DeepMocked; let schoolSystemOptionsService: DeepMocked; + let provisioningOptionsUpdateService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -38,6 +39,10 @@ describe(SchoolSystemOptionsUc.name, () => { provide: SchoolSystemOptionsService, useValue: createMock(), }, + { + provide: ProvisioningOptionsUpdateService, + useValue: createMock(), + }, ], }).compile(); @@ -45,6 +50,7 @@ describe(SchoolSystemOptionsUc.name, () => { authorizationService = module.get(AuthorizationService); systemService = module.get(SystemService); schoolSystemOptionsService = module.get(SchoolSystemOptionsService); + provisioningOptionsUpdateService = module.get(ProvisioningOptionsUpdateService); }); afterAll(async () => { @@ -124,6 +130,11 @@ describe(SchoolSystemOptionsUc.name, () => { const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: system.id, + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }), }); systemService.findById.mockResolvedValueOnce(system); @@ -154,6 +165,24 @@ describe(SchoolSystemOptionsUc.name, () => { ); }); + it('should execute additional update actions', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(provisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions, + new SchulConneXProvisioningOptions() + ); + }); + it('should save the options', async () => { const { user, schoolSystemOptions } = setup(); @@ -189,60 +218,93 @@ describe(SchoolSystemOptionsUc.name, () => { const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: system.id, + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }), + }); + const newOptions: AnyProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, }); systemService.findById.mockResolvedValueOnce(system); schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(schoolSystemOptions); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolSystemOptionsService.save.mockResolvedValueOnce(schoolSystemOptions); + schoolSystemOptionsService.save.mockResolvedValueOnce( + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }) + ); return { user, schoolSystemOptions, + newOptions, }; }; it('should check the permissions', async () => { - const { user, schoolSystemOptions } = setup(); + const { user, schoolSystemOptions, newOptions } = setup(); await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, - schoolSystemOptions.provisioningOptions + newOptions ); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, - schoolSystemOptions, + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }), AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) ); }); - it('should save the options', async () => { - const { user, schoolSystemOptions } = setup(); + it('should execute additional update actions', async () => { + const { user, schoolSystemOptions, newOptions } = setup(); await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, + newOptions + ); + + expect(provisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newOptions, schoolSystemOptions.provisioningOptions ); + }); - expect(schoolSystemOptionsService.save).toHaveBeenCalledWith(schoolSystemOptions); + it('should save the options', async () => { + const { user, schoolSystemOptions, newOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newOptions + ); + + expect(schoolSystemOptionsService.save).toHaveBeenCalledWith( + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }) + ); }); it('should return the options', async () => { - const { user, schoolSystemOptions } = setup(); + const { user, schoolSystemOptions, newOptions } = setup(); const result: AnyProvisioningOptions = await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, - schoolSystemOptions.provisioningOptions + newOptions ); - expect(result).toEqual(schoolSystemOptions.provisioningOptions); + expect(result).toEqual(newOptions); }); }); diff --git a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts index ad4e0d1da59..7f8e6b57dec 100644 --- a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts @@ -8,14 +8,15 @@ import { EntityId } from '@shared/domain/types'; import { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsBuilder } from '../domain'; import { ProvisioningOptionsInterface } from '../interface'; import { ProvisioningStrategyMissingLoggableException } from '../loggable'; -import { SchoolSystemOptionsService } from '../service'; +import { ProvisioningOptionsUpdateService, SchoolSystemOptionsService } from '../service'; @Injectable() export class SchoolSystemOptionsUc { constructor( private readonly authorizationService: AuthorizationService, private readonly systemService: SystemService, - private readonly schoolSystemOptionsService: SchoolSystemOptionsService + private readonly schoolSystemOptionsService: SchoolSystemOptionsService, + private readonly provisioningOptionsUpdateService: ProvisioningOptionsUpdateService ) {} public async getProvisioningOptions( @@ -56,9 +57,12 @@ export class SchoolSystemOptionsUc { throw new ProvisioningStrategyMissingLoggableException(systemId); } - const provisioningOptions: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + const schoolSystemOptionsBuilder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( system.provisioningStrategy - ).buildProvisioningOptions(requestedProvisioningOptions); + ); + + const newProvisioningOptions: AnyProvisioningOptions = + schoolSystemOptionsBuilder.buildProvisioningOptions(requestedProvisioningOptions); const existingSchoolSystemOptions: SchoolSystemOptions | null = await this.schoolSystemOptionsService.findBySchoolIdAndSystemId(schoolId, systemId); @@ -67,7 +71,7 @@ export class SchoolSystemOptionsUc { id: existingSchoolSystemOptions?.id ?? new ObjectId().toHexString(), systemId, schoolId, - provisioningOptions, + provisioningOptions: newProvisioningOptions, }); const user = await this.authorizationService.getUserWithPermissions(userId); @@ -77,6 +81,15 @@ export class SchoolSystemOptionsUc { AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) ); + const currentProvisioningOptions: AnyProvisioningOptions = + existingSchoolSystemOptions?.provisioningOptions ?? schoolSystemOptionsBuilder.getDefaultProvisioningOptions(); + await this.provisioningOptionsUpdateService.handleUpdate( + schoolId, + systemId, + newProvisioningOptions, + currentProvisioningOptions + ); + const savedSchoolSystemOptions: SchoolSystemOptions = await this.schoolSystemOptionsService.save( schoolSystemOptions ); diff --git a/apps/server/src/modules/provisioning/config/provisioning-config.ts b/apps/server/src/modules/provisioning/config/provisioning-config.ts index 4f6d83fc19d..b36e52ea050 100644 --- a/apps/server/src/modules/provisioning/config/provisioning-config.ts +++ b/apps/server/src/modules/provisioning/config/provisioning-config.ts @@ -4,12 +4,10 @@ export const ProvisioningFeatures = Symbol('ProvisioningFeatures'); export interface IProvisioningFeatures { schulconnexGroupProvisioningEnabled: boolean; - provisioningOptionsEnabled: boolean; } export class ProvisioningConfiguration { static provisioningFeatures: IProvisioningFeatures = { schulconnexGroupProvisioningEnabled: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, - provisioningOptionsEnabled: Configuration.get('FEATURE_PROVISIONING_OPTIONS_ENABLED') as boolean, }; } diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts index 22f702f2b1d..47068ab32e2 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts @@ -4,15 +4,14 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { legacySchoolDoFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import jwt from 'jsonwebtoken'; +import { legacySchoolDoFactory, userDoFactory } from '@shared/testing'; import { IdTokenExtractionFailureLoggableException, IdTokenUserNotFoundLoggableException, } from '@src/modules/oauth/loggable'; +import jwt from 'jsonwebtoken'; import { RoleDto } from '../../../role/service/dto/role.dto'; import { ExternalSchoolDto, @@ -34,7 +33,6 @@ describe('IservProvisioningStrategy', () => { let userService: DeepMocked; beforeAll(async () => { - await setupEntities(); module = await Test.createTestingModule({ providers: [ IservProvisioningStrategy, @@ -141,9 +139,9 @@ describe('IservProvisioningStrategy', () => { it('should throw an error with code sso_user_notfound and additional information', async () => { const { input, userUUID, email } = setup(); const schoolId: string = new ObjectId().toHexString(); - const user: User = userFactory.buildWithId({ + const user: UserDO = userDoFactory.buildWithId({ externalId: userUUID, - school: schoolFactory.buildWithId(undefined, schoolId), + schoolId, }); jest.spyOn(jwt, 'decode').mockImplementation(() => { diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts index 9567bcb4664..841daaee78e 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts @@ -1,15 +1,14 @@ import { LegacySchoolService } from '@modules/legacy-school'; +import { + IdTokenExtractionFailureLoggableException, + IdTokenUserNotFoundLoggableException, +} from '@modules/oauth/loggable'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { - IdTokenExtractionFailureLoggableException, - IdTokenUserNotFoundLoggableException, -} from '@modules/oauth/loggable'; import { ExternalSchoolDto, ExternalUserDto, @@ -66,10 +65,10 @@ export class IservProvisioningStrategy extends ProvisioningStrategy { async getAdditionalErrorInfo(email: string | undefined): Promise { if (email) { - const usersWithEmail: User[] = await this.userService.findByEmail(email); + const usersWithEmail: UserDO[] = await this.userService.findByEmail(email); if (usersWithEmail.length > 0) { - const user: User = usersWithEmail[0]; - return ` [schoolId: ${user.school.id}, currentLdapId: ${user.externalId ?? ''}]`; + const user: UserDO = usersWithEmail[0]; + return ` [schoolId: ${user.schoolId}, currentLdapId: ${user.externalId ?? ''}]`; } } return ''; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts index 80c078b52dd..7b6722f735c 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts @@ -65,7 +65,6 @@ describe('OidcStrategy', () => { beforeEach(() => { Object.assign>(provisioningFeatures, { schulconnexGroupProvisioningEnabled: false, - provisioningOptionsEnabled: false, }); }); @@ -194,7 +193,6 @@ describe('OidcStrategy', () => { describe('when group data is provided and the feature is enabled', () => { const setup = () => { provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - provisioningFeatures.provisioningOptionsEnabled = true; const externalUserId = 'externalUserId'; const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts index 6fe09cd357c..e1e2f33e2f1 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -36,9 +36,7 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { if (data.externalGroups) { let groups: ExternalGroupDto[] = data.externalGroups; - if (this.provisioningFeatures.provisioningOptionsEnabled) { - groups = await this.oidcProvisioningService.filterExternalGroups(groups, school?.id, data.system.systemId); - } + groups = await this.oidcProvisioningService.filterExternalGroups(groups, school?.id, data.system.systemId); await Promise.all( groups.map((group: ExternalGroupDto) => diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts index 248ba25913f..fd94f69d66e 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { LegacySchoolService } from '@modules/legacy-school'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolFeature } from '@shared/domain/types'; -import { setupEntities, userLoginMigrationDOFactory } from '@shared/testing'; +import { userLoginMigrationDOFactory } from '@shared/testing'; import { UserLoginMigrationRevertService } from './user-login-migration-revert.service'; import { UserLoginMigrationService } from './user-login-migration.service'; @@ -14,8 +14,6 @@ describe('UserLoginMigrationRevertService', () => { let userLoginMigrationService: DeepMocked; beforeAll(async () => { - await setupEntities(); - module = await Test.createTestingModule({ providers: [ UserLoginMigrationRevertService, diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index c7223c45322..310f5ccdfef 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -1,5 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AccountDto, AccountService } from '@modules/account'; import { OauthCurrentUser } from '@modules/authentication/interface'; import { RoleService } from '@modules/role'; @@ -12,7 +13,6 @@ import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; import { UserDto } from '../uc/dto/user.dto'; import { UserQuery } from './user-query.type'; import { UserService } from './user.service'; @@ -346,11 +346,11 @@ describe('UserService', () => { describe('findByEmail is called', () => { describe('when a user with this email exists', () => { it('should return the user', async () => { - const user: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId(); - userRepo.findByEmail.mockResolvedValue([user]); + userDORepo.findByEmail.mockResolvedValue([user]); - const result: User[] = await service.findByEmail(user.email); + const result: UserDO[] = await service.findByEmail(user.email); expect(result).toEqual([user]); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 4e8f2fe2394..ce2fe2316fa 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -90,8 +90,8 @@ export class UserService { return user; } - async findByEmail(email: string): Promise { - const user: Promise = this.userRepo.findByEmail(email); + async findByEmail(email: string): Promise { + const user: Promise = this.userDORepo.findByEmail(email); return user; } diff --git a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts index 6fdbe820dc7..baa3efbcd89 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts @@ -181,6 +181,51 @@ describe('UserRepo', () => { }); }); + describe('findByEmail', () => { + it('should find user by email', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + const result = await repo.findByEmail('USER@EXAMPLE.COM'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + }); + + it('should find user by email, ignoring case', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + let result: UserDO[]; + + result = await repo.findByEmail('USER@example.COM'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + + result = await repo.findByEmail('user@example.com'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + }); + + it('should not find by wildcard', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + let result: UserDO[]; + + result = await repo.findByEmail('USER@EXAMPLECCOM'); + expect(result).toHaveLength(0); + + result = await repo.findByEmail('.*'); + expect(result).toHaveLength(0); + }); + }); + describe('mapEntityToDO', () => { it('should return a domain object', () => { const id = new ObjectId(); diff --git a/apps/server/src/shared/repo/user/user-do.repo.ts b/apps/server/src/shared/repo/user/user-do.repo.ts index 1ab196fc3b5..3e363f03120 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -90,6 +90,17 @@ export class UserDORepo extends BaseDORepo { return userDo; } + async findByEmail(email: string): Promise { + // find mail case-insensitive by regex + const userEntitys: User[] = await this._em.find(User, { + email: new RegExp(`^${email.replace(/\W/g, '\\$&')}$`, 'i'), + }); + + const userDos: UserDO[] = userEntitys.map((userEntity: User): UserDO => this.mapEntityToDO(userEntity)); + + return userDos; + } + mapEntityToDO(entity: User): UserDO { const user: UserDO = new UserDO({ id: entity.id, diff --git a/backup/setup/schools.json b/backup/setup/schools.json index 29ce522fe58..a6382fa5381 100644 --- a/backup/setup/schools.json +++ b/backup/setup/schools.json @@ -193,7 +193,9 @@ "$oid": "5fa318f2b229544f2c697a56" }, "documentBaseDirType": "", - "systems": [], + "systems": [{ + "$oid": "62c7f233f35a554ba3ed0000" + }], "experimental": false, "pilot": false, "features": [ diff --git a/config/default.schema.json b/config/default.schema.json index 0104563c2d2..9774124f911 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1369,11 +1369,6 @@ "default": false, "description": "Enables external tools on the column board" }, - "FEATURE_PROVISIONING_OPTIONS_ENABLED": { - "type": "boolean", - "default": false, - "description": "enables to view the page to set group provisioning options for a school and system pair" - }, "CTL_TOOLS": { "type": "object", "description": "CTL Tools properties", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 0b429a46048..cdf6a815a25 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -65,7 +65,6 @@ const exposedVars = [ 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', 'FEATURE_TLDRAW_ENABLED', - 'FEATURE_PROVISIONING_OPTIONS_ENABLED', ]; /** From 272bffe3c77b853ad0cc76c71c0f9d0a1829a142 Mon Sep 17 00:00:00 2001 From: bergatco <129839305+bergatco@users.noreply.github.com> Date: Wed, 10 Jan 2024 10:49:40 +0100 Subject: [PATCH 22/25] BC-6040 - Redis Packages Update for Server and legacy client (#4641) --- apps/server/src/infra/cache/cache.module.ts | 9 +- apps/server/src/infra/redis/redis.module.ts | 4 +- .../src/modules/server/server.module.ts | 19 +- package-lock.json | 557 ++++++++++++------ package.json | 11 +- src/app.js | 2 +- src/utils/redis.js | 5 +- 7 files changed, 397 insertions(+), 210 deletions(-) diff --git a/apps/server/src/infra/cache/cache.module.ts b/apps/server/src/infra/cache/cache.module.ts index 897e93926d1..88ac200c148 100644 --- a/apps/server/src/infra/cache/cache.module.ts +++ b/apps/server/src/infra/cache/cache.module.ts @@ -2,19 +2,18 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { CacheModule, CacheModuleOptions } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import { create } from 'cache-manager-redis-store'; -import { RedisClient } from 'redis'; +import { redisStore } from 'cache-manager-redis-yet'; import { CacheStoreType } from './interface'; import { CacheService } from './service/cache.service'; @Module({ imports: [ CacheModule.registerAsync({ - useFactory: (cacheService: CacheService, logger: LegacyLogger): CacheModuleOptions => { + useFactory: async (cacheService: CacheService, logger: LegacyLogger): Promise => { if (cacheService.getStoreType() === CacheStoreType.REDIS) { const redisUrl: string = Configuration.get('REDIS_URI') as string; - const store = create({ url: redisUrl }); - const client: RedisClient = store.getClient(); + const store = await redisStore({ url: redisUrl }); + const { client } = store; client.on('error', (error) => logger.error(error)); client.on('connect', (msg) => logger.log(msg)); diff --git a/apps/server/src/infra/redis/redis.module.ts b/apps/server/src/infra/redis/redis.module.ts index 1807e98a05e..569fad4e101 100644 --- a/apps/server/src/infra/redis/redis.module.ts +++ b/apps/server/src/infra/redis/redis.module.ts @@ -1,7 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Module } from '@nestjs/common'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import { createClient, RedisClient } from 'redis'; +import { createClient, RedisClientType } from 'redis'; import { REDIS_CLIENT } from './interface/redis.constants'; @Module({ @@ -14,7 +14,7 @@ import { REDIS_CLIENT } from './interface/redis.constants'; if (Configuration.has('REDIS_URI')) { const redisUrl: string = Configuration.get('REDIS_URI') as string; - const client: RedisClient = createClient({ url: redisUrl }); + const client: RedisClientType = createClient({ url: redisUrl }); client.on('error', (error) => logger.error(error)); client.on('connect', (msg) => logger.log(msg)); diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 4839089ece5..1f4bcc608df 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -2,7 +2,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { MailModule } from '@infra/mail'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; -import { REDIS_CLIENT, RedisModule } from '@infra/redis'; +import { RedisModule, REDIS_CLIENT } from '@infra/redis'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AccountApiModule } from '@modules/account/account-api.module'; @@ -36,9 +36,9 @@ import { ALL_ENTITIES } from '@shared/domain/entity'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import connectRedis from 'connect-redis'; +import RedisStore from 'connect-redis'; import session from 'express-session'; -import { RedisClient } from 'redis'; +import { RedisClientType } from 'redis'; import { ServerController } from './controller/server.controller'; import { serverConfig } from './server.config'; @@ -89,12 +89,15 @@ export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), }; -const setupSessions = (consumer: MiddlewareConsumer, redisClient: RedisClient | undefined, logger: LegacyLogger) => { +const setupSessions = ( + consumer: MiddlewareConsumer, + redisClient: RedisClientType | undefined, + logger: LegacyLogger +) => { const sessionDuration: number = Configuration.get('SESSION__EXPIRES_SECONDS') as number; - let store: connectRedis.RedisStore | undefined; + let store: RedisStore | undefined; if (redisClient) { - const RedisStore: connectRedis.RedisStore = connectRedis(session); store = new RedisStore({ client: redisClient, ttl: sessionDuration, @@ -150,7 +153,7 @@ const setupSessions = (consumer: MiddlewareConsumer, redisClient: RedisClient | }) export class ServerModule implements NestModule { constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClient | undefined, + @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, private readonly logger: LegacyLogger ) { logger.setContext(ServerModule.name); @@ -181,7 +184,7 @@ export class ServerModule implements NestModule { }) export class ServerTestModule implements NestModule { constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClient | undefined, + @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, private readonly logger: LegacyLogger ) { logger.setContext(ServerTestModule.name); diff --git a/package-lock.json b/package-lock.json index 27cb713124a..3e348b83388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,11 +37,8 @@ "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", "@nestjs/websockets": "^10.2.4", - "@types/cache-manager-redis-store": "^2.0.1", - "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", - "@types/redis": "^2.8.32", "@types/xml2js": "^0.4.11", "adm-zip": "^0.5.9", "ajv": "^8.8.2", @@ -59,8 +56,8 @@ "body-parser": "^1.15.2", "bson": "^4.6.0", "busboy": "^1.6.0", - "cache-manager": "^2.9.0", - "cache-manager-redis-store": "^2.0.0", + "cache-manager": "^5.3.1", + "cache-manager-redis-yet": "^4.1.2", "chalk": "^5.0.0", "clamscan": "^2.1.2", "class-transformer": "^0.4.0", @@ -69,7 +66,7 @@ "commander": "^8.1.0", "compression": "^1.6.2", "concurrently": "^6.0.0", - "connect-redis": "^6.1.3", + "connect-redis": "^7.1.0", "cors": "^2.8.1", "cross-env": "^7.0.0", "crypto-js": "^4.2.0", @@ -116,7 +113,7 @@ "prom-client": "^13.1.0", "qs": "^6.9.7", "read-chunk": "^3.0.0", - "redis": "^3.0.0", + "redis": "^4.6.11", "reflect-metadata": "^0.1.13", "request-promise-core": "^1.1.4", "request-promise-native": "^1.0.3", @@ -219,7 +216,7 @@ }, "engines": { "node": "18", - "npm": "9" + "npm": ">=9" } }, "node_modules/@ampproject/remapping": { @@ -3510,6 +3507,13 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "optional": true, + "peer": true + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5505,6 +5509,59 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.12.tgz", + "integrity": "sha512-/ZjE18HRzMd80eXIIUIPcH81UoZpwulbo8FmbElrjPqH0QC0SeIKu1BOU49bO5trM5g895kAjhvalt5h77q+4A==", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@servie/events": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@servie/events/-/events-1.0.0.tgz", @@ -5722,20 +5779,6 @@ "@types/node": "*" } }, - "node_modules/@types/cache-manager": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.2.tgz", - "integrity": "sha512-fT5FMdzsiSX0AbgnS5gDvHl2Nco0h5zYyjwDQy4yPC7Ww6DeGMVKPRqIZtg9HOXDV2kkc18SL1B0N8f0BecrCA==" - }, - "node_modules/@types/cache-manager-redis-store": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.1.tgz", - "integrity": "sha512-8QuccvcPieh1xM/5kReE76SfdcIdEB0ePc+54ah/NBuK2eG+6O50SX4WKoJX81UxGdW3sh/WlDaDNqjnqxWNsA==", - "dependencies": { - "@types/cache-manager": "*", - "@types/redis": "^2.8.0" - } - }, "node_modules/@types/chai": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", @@ -5782,17 +5825,6 @@ "@types/node": "*" } }, - "node_modules/@types/connect-redis": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.19.tgz", - "integrity": "sha512-2312okmqA8MtogPkLmgwmM12VeSYH8gUAuRSzAtVz3PBoyEZwqt7Ri1lXBFtJmIVd3oXC/Hvg1KJSkt9x2ukKw==", - "dependencies": { - "@types/express": "*", - "@types/express-session": "*", - "@types/ioredis": "^4.28.10", - "@types/redis": "^2.8.0" - } - }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -5886,6 +5918,7 @@ "version": "1.17.5", "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.5.tgz", "integrity": "sha512-l0DhkvNVfyUPEEis8fcwbd46VptfA/jmMwHfob2TfDMf3HyPLiB9mKD71LXhz5TMUobODXPD27zXSwtFQLHm+w==", + "dev": true, "dependencies": { "@types/express": "*" } @@ -5925,14 +5958,6 @@ "@types/node": "*" } }, - "node_modules/@types/ioredis": { - "version": "4.28.10", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", - "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -6097,14 +6122,6 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, - "node_modules/@types/redis": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", - "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/response-time": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@types/response-time/-/response-time-2.3.5.tgz", @@ -8075,44 +8092,41 @@ } }, "node_modules/cache-manager": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-2.9.0.tgz", - "integrity": "sha512-s/R6ePETFqGZzmnJnd2A4HYfmC9oR+8xT7REeRgRvvl0ztIp1T+VopbwSy6N5OXUYZ/gkPFWqY+F+652FDbAJw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.3.1.tgz", + "integrity": "sha512-9HP6nc1ZqyZgcVEpy5XS2ns9MYE6cPEM6InA1wQhR6M7GviJzLH2NTFYnf3NEfRmLE351NCSkDo2VISX8dlG+w==", "dependencies": { - "async": "1.5.2", - "lru-cache": "4.0.0" + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.2", + "promise-coalesce": "^1.1.1" } }, - "node_modules/cache-manager-redis-store": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz", - "integrity": "sha512-bWLWlUg6nCYHiJLCCYxY2MgvwvKnvlWwrbuynrzpjEIhfArD2GC9LtutIHFEPeyGVQN6C+WEw+P3r+BFBwhswg==", + "node_modules/cache-manager-redis-yet": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-4.1.2.tgz", + "integrity": "sha512-pM2K1ZlOv8gQpE1Z5mcDrfLj5CsNKVRiYua/SZ12j7LEDgfDeFVntI6JSgIw0siFSR/9P/FpG30scI3frHwibA==", "dependencies": { - "redis": "^3.0.2" + "@redis/bloom": "^1.2.0", + "@redis/client": "^1.5.8", + "@redis/graph": "^1.1.0", + "@redis/json": "^1.0.4", + "@redis/search": "^1.1.3", + "@redis/time-series": "^1.0.4", + "cache-manager": "^5.2.2", + "redis": "^4.6.7" }, "engines": { - "node": ">= 8.3" + "node": ">= 16.17.0" } }, - "node_modules/cache-manager/node_modules/async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==" - }, "node_modules/cache-manager/node_modules/lru-cache": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.0.tgz", - "integrity": "sha512-WKhDkjlLwzE8jAQdQlsxLUQTPXLCKX/4cJk6s5AlRtJkDBk0IKH5O51bVDH61K9N4bhbbyvLM6EiOuE8ovApPA==", - "dependencies": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "engines": { + "node": "14 || >=16.14" } }, - "node_modules/cache-manager/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" - }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -8681,6 +8695,14 @@ "node": ">=0.10.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmake-js": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", @@ -9225,11 +9247,14 @@ "dev": true }, "node_modules/connect-redis": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", - "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.0.tgz", + "integrity": "sha512-UaqO1EirWjON2ENsyau7N5lbkrdYBpS6mYlXSeff/OYXsd6EGZ+SXSmNPoljL2PSua8fgjAEaldSA73PMZQ9Eg==", "engines": { - "node": ">=12" + "node": ">=16" + }, + "peerDependencies": { + "express-session": ">=1" } }, "node_modules/consola": { @@ -12609,6 +12634,14 @@ "lodash.padstart": "^4.1.0" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -13465,6 +13498,59 @@ "node": ">=0.10.0" } }, + "node_modules/ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "optional": true, + "peer": true, + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -16605,6 +16691,13 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "optional": true, + "peer": true + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -16621,6 +16714,13 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "optional": true, + "peer": true + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -20709,6 +20809,14 @@ "resolved": "https://registry.npmjs.org/promise-breaker/-/promise-breaker-5.0.0.tgz", "integrity": "sha512-mgsWQuG4kJ1dtO6e/QlNDLFtMkMzzecsC69aI5hlLEjGHFNpHrvGhFi4LiK5jg2SMQj74/diH+wZliL9LpGsyA==" }, + "node_modules/promise-coalesce": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.1.tgz", + "integrity": "sha512-k7+VaIwZc5dRfSF6RELqRY1+LCmcCkrnuNV9HzIpA6iwRHKke+j9yb0LBTTHQ2RRgf6AlMl9TntuTzcgV/BZwg==", + "engines": { + "node": ">=18" + } + }, "node_modules/promisepipe": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", @@ -21139,32 +21247,24 @@ } }, "node_modules/redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "version": "4.6.11", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.11.tgz", + "integrity": "sha512-kg1Lt4NZLYkAjPOj/WcyIGWfZfnyfKo1Wg9YKVSlzhFwxpFIl3LYI8BWy1Ab963LLDsTz2+OwdsesHKljB3WMQ==", "dependencies": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-redis" + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.12", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" } }, - "node_modules/redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" - }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -21172,7 +21272,9 @@ "node_modules/redis-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "optional": true, + "peer": true, "dependencies": { "redis-errors": "^1.0.0" }, @@ -22943,6 +23045,13 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "optional": true, + "peer": true + }, "node_modules/static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", @@ -28229,6 +28338,13 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "optional": true, + "peer": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -29578,6 +29694,46 @@ "tslib": "^2.4.0" } }, + "@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "requires": {} + }, + "@redis/client": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.12.tgz", + "integrity": "sha512-/ZjE18HRzMd80eXIIUIPcH81UoZpwulbo8FmbElrjPqH0QC0SeIKu1BOU49bO5trM5g895kAjhvalt5h77q+4A==", + "requires": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + } + }, + "@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "requires": {} + }, + "@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "requires": {} + }, + "@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "requires": {} + }, + "@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "requires": {} + }, "@servie/events": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@servie/events/-/events-1.0.0.tgz", @@ -29789,20 +29945,6 @@ "@types/node": "*" } }, - "@types/cache-manager": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.2.tgz", - "integrity": "sha512-fT5FMdzsiSX0AbgnS5gDvHl2Nco0h5zYyjwDQy4yPC7Ww6DeGMVKPRqIZtg9HOXDV2kkc18SL1B0N8f0BecrCA==" - }, - "@types/cache-manager-redis-store": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.1.tgz", - "integrity": "sha512-8QuccvcPieh1xM/5kReE76SfdcIdEB0ePc+54ah/NBuK2eG+6O50SX4WKoJX81UxGdW3sh/WlDaDNqjnqxWNsA==", - "requires": { - "@types/cache-manager": "*", - "@types/redis": "^2.8.0" - } - }, "@types/chai": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", @@ -29851,17 +29993,6 @@ "@types/node": "*" } }, - "@types/connect-redis": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.19.tgz", - "integrity": "sha512-2312okmqA8MtogPkLmgwmM12VeSYH8gUAuRSzAtVz3PBoyEZwqt7Ri1lXBFtJmIVd3oXC/Hvg1KJSkt9x2ukKw==", - "requires": { - "@types/express": "*", - "@types/express-session": "*", - "@types/ioredis": "^4.28.10", - "@types/redis": "^2.8.0" - } - }, "@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -29955,6 +30086,7 @@ "version": "1.17.5", "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.5.tgz", "integrity": "sha512-l0DhkvNVfyUPEEis8fcwbd46VptfA/jmMwHfob2TfDMf3HyPLiB9mKD71LXhz5TMUobODXPD27zXSwtFQLHm+w==", + "dev": true, "requires": { "@types/express": "*" } @@ -29994,14 +30126,6 @@ "@types/node": "*" } }, - "@types/ioredis": { - "version": "4.28.10", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", - "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", - "requires": { - "@types/node": "*" - } - }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -30165,14 +30289,6 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, - "@types/redis": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", - "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", - "requires": { - "@types/node": "*" - } - }, "@types/response-time": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@types/response-time/-/response-time-2.3.5.tgz", @@ -31703,41 +31819,35 @@ "dev": true }, "cache-manager": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-2.9.0.tgz", - "integrity": "sha512-s/R6ePETFqGZzmnJnd2A4HYfmC9oR+8xT7REeRgRvvl0ztIp1T+VopbwSy6N5OXUYZ/gkPFWqY+F+652FDbAJw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.3.1.tgz", + "integrity": "sha512-9HP6nc1ZqyZgcVEpy5XS2ns9MYE6cPEM6InA1wQhR6M7GviJzLH2NTFYnf3NEfRmLE351NCSkDo2VISX8dlG+w==", "requires": { - "async": "1.5.2", - "lru-cache": "4.0.0" + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.2", + "promise-coalesce": "^1.1.1" }, "dependencies": { - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==" - }, "lru-cache": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.0.tgz", - "integrity": "sha512-WKhDkjlLwzE8jAQdQlsxLUQTPXLCKX/4cJk6s5AlRtJkDBk0IKH5O51bVDH61K9N4bhbbyvLM6EiOuE8ovApPA==", - "requires": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==" } } }, - "cache-manager-redis-store": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz", - "integrity": "sha512-bWLWlUg6nCYHiJLCCYxY2MgvwvKnvlWwrbuynrzpjEIhfArD2GC9LtutIHFEPeyGVQN6C+WEw+P3r+BFBwhswg==", + "cache-manager-redis-yet": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-4.1.2.tgz", + "integrity": "sha512-pM2K1ZlOv8gQpE1Z5mcDrfLj5CsNKVRiYua/SZ12j7LEDgfDeFVntI6JSgIw0siFSR/9P/FpG30scI3frHwibA==", "requires": { - "redis": "^3.0.2" + "@redis/bloom": "^1.2.0", + "@redis/client": "^1.5.8", + "@redis/graph": "^1.1.0", + "@redis/json": "^1.0.4", + "@redis/search": "^1.1.3", + "@redis/time-series": "^1.0.4", + "cache-manager": "^5.2.2", + "redis": "^4.6.7" } }, "caching-transform": { @@ -32159,6 +32269,11 @@ } } }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "cmake-js": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", @@ -32622,9 +32737,10 @@ "dev": true }, "connect-redis": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", - "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==" + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.0.tgz", + "integrity": "sha512-UaqO1EirWjON2ENsyau7N5lbkrdYBpS6mYlXSeff/OYXsd6EGZ+SXSmNPoljL2PSua8fgjAEaldSA73PMZQ9Eg==", + "requires": {} }, "consola": { "version": "2.15.3", @@ -35084,6 +35200,11 @@ "lodash.padstart": "^4.1.0" } }, + "generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -35713,6 +35834,43 @@ "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" }, + "ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "optional": true, + "peer": true, + "requires": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "peer": true, + "requires": { + "ms": "2.1.2" + } + }, + "denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "optional": true, + "peer": true + } + } + }, "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -38076,6 +38234,13 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "optional": true, + "peer": true + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -38092,6 +38257,13 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "optional": true, + "peer": true + }, "lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -41218,6 +41390,11 @@ "resolved": "https://registry.npmjs.org/promise-breaker/-/promise-breaker-5.0.0.tgz", "integrity": "sha512-mgsWQuG4kJ1dtO6e/QlNDLFtMkMzzecsC69aI5hlLEjGHFNpHrvGhFi4LiK5jg2SMQj74/diH+wZliL9LpGsyA==" }, + "promise-coalesce": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.1.tgz", + "integrity": "sha512-k7+VaIwZc5dRfSF6RELqRY1+LCmcCkrnuNV9HzIpA6iwRHKke+j9yb0LBTTHQ2RRgf6AlMl9TntuTzcgV/BZwg==" + }, "promisepipe": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", @@ -41562,30 +41739,31 @@ } }, "redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "version": "4.6.11", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.11.tgz", + "integrity": "sha512-kg1Lt4NZLYkAjPOj/WcyIGWfZfnyfKo1Wg9YKVSlzhFwxpFIl3LYI8BWy1Ab963LLDsTz2+OwdsesHKljB3WMQ==", "requires": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.12", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" } }, - "redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" - }, "redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "optional": true, + "peer": true }, "redis-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "optional": true, + "peer": true, "requires": { "redis-errors": "^1.0.0" } @@ -42966,6 +43144,13 @@ } } }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "optional": true, + "peer": true + }, "static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", diff --git a/package.json b/package.json index c4d7d4026e5..656edf9a2d4 100644 --- a/package.json +++ b/package.json @@ -132,11 +132,8 @@ "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", "@nestjs/websockets": "^10.2.4", - "@types/cache-manager-redis-store": "^2.0.1", - "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", - "@types/redis": "^2.8.32", "@types/xml2js": "^0.4.11", "adm-zip": "^0.5.9", "ajv": "^8.8.2", @@ -154,8 +151,8 @@ "body-parser": "^1.15.2", "bson": "^4.6.0", "busboy": "^1.6.0", - "cache-manager": "^2.9.0", - "cache-manager-redis-store": "^2.0.0", + "cache-manager": "^5.3.1", + "cache-manager-redis-yet": "^4.1.2", "chalk": "^5.0.0", "clamscan": "^2.1.2", "class-transformer": "^0.4.0", @@ -164,7 +161,7 @@ "commander": "^8.1.0", "compression": "^1.6.2", "concurrently": "^6.0.0", - "connect-redis": "^6.1.3", + "connect-redis": "^7.1.0", "cors": "^2.8.1", "cross-env": "^7.0.0", "crypto-js": "^4.2.0", @@ -211,7 +208,7 @@ "prom-client": "^13.1.0", "qs": "^6.9.7", "read-chunk": "^3.0.0", - "redis": "^3.0.0", + "redis": "^4.6.11", "reflect-metadata": "^0.1.13", "request-promise-core": "^1.1.4", "request-promise-native": "^1.0.3", diff --git a/src/app.js b/src/app.js index 4106bfa514d..535493593df 100644 --- a/src/app.js +++ b/src/app.js @@ -48,7 +48,7 @@ const setupApp = async (orm) => { setupFacadeLocator(app); setupSwagger(app); - initializeRedisClient(); + await initializeRedisClient(); rabbitMq.setup(app); app .use(compress()) diff --git a/src/utils/redis.js b/src/utils/redis.js index 1464fb706cc..2cdaaa0a7fc 100644 --- a/src/utils/redis.js +++ b/src/utils/redis.js @@ -6,12 +6,15 @@ const { GeneralError } = require('../errors'); let redisClient = false; -function initializeRedisClient() { +async function initializeRedisClient() { if (Configuration.has('REDIS_URI')) { try { redisClient = redis.createClient({ url: Configuration.get('REDIS_URI'), + // Legacy mode is needed for compatibility with v4, see https://github.com/redis/node-redis/blob/HEAD/docs/v3-to-v4.md#legacy-mode + legacyMode: true, }); + await redisClient.connect(); } catch (err) { throw new GeneralError('Redis connection failed!', err); } From 1456241f8ff4e589e8b7567fd305f92a3a98fbe4 Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Thu, 11 Jan 2024 13:48:02 +0100 Subject: [PATCH 23/25] BC-6231 - fix feathers $limit=false (#4690) Co-authored-by: SevenWaysDP --- src/utils/feathers-mongoose/service.js | 11 ++++++++ test/services/school/index.test.js | 36 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/utils/feathers-mongoose/service.js b/src/utils/feathers-mongoose/service.js index 04522a0756f..f2c1a860394 100644 --- a/src/utils/feathers-mongoose/service.js +++ b/src/utils/feathers-mongoose/service.js @@ -462,6 +462,17 @@ class Service extends AdapterBase { filterQuery(params) { const options = this.getOptions(params); + + // $limit=false - should return all records with structure as with pagination (total, limit, skip, data) + const paginateNoLimit = { + default: undefined, + max: undefined, + }; + if (params.query && (params.query.$limit === 'false' || params.query.$limit === false)) { + options.paginate = paginateNoLimit; + params.query.$limit = undefined; + } + const { $select, $sort, $limit: _limit, $skip = 0, $populate, ...query } = params.query || {}; const $limit = getLimit(_limit, options.paginate); diff --git a/test/services/school/index.test.js b/test/services/school/index.test.js index 93c8bebea67..f1817289b53 100644 --- a/test/services/school/index.test.js +++ b/test/services/school/index.test.js @@ -575,6 +575,42 @@ describe('school service', () => { }); }); +describe('find schools', () => { + let app; + let server; + let schoolsService; + + before(async () => { + app = await appPromise(); + server = await app.listen(); + schoolsService = app.service('schools'); + }); + + after(async () => { + await server.close(); + }); + + afterEach(async () => { + await testObjects.cleanup(); + }); + + beforeEach('set data samples', async () => { + await testObjects.createTestSchool({}); + await testObjects.createTestSchool({}); + await testObjects.createTestSchool({}); + }); + + it('find with pagination and limit', async () => { + const result = await schoolsService.find({ query: { $limit: 2 } }); + expect(result.data.length).to.be.equal(2); + }); + + it('find should return all schools when $limit = false', async () => { + const result = await schoolsService.find({ query: { $limit: false } }); + expect(result.data.length).to.be.equal(result.total); + }); +}); + describe('years service', () => { let app; let server; From 003e7d4d5cd49fa5c76c768e2f4a82801958c7e2 Mon Sep 17 00:00:00 2001 From: blazejpass <118356546+blazejpass@users.noreply.github.com> Date: Thu, 11 Jan 2024 17:31:20 +0100 Subject: [PATCH 24/25] BC-4709 Create authorisation service (#4614) * Add authorisation Co-authored-by: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> --- .../drawing-item-check-permission.api.spec.ts | 133 ++++++++++++ .../board/controller/element.controller.ts | 14 ++ .../src/modules/board/uc/element.uc.spec.ts | 66 +++++- .../server/src/modules/board/uc/element.uc.ts | 5 + apps/server/src/modules/tldraw/config.ts | 2 + .../api-test/tldraw.controller.api.spec.ts | 69 +++++++ .../controller/api-test/tldraw.ws.api.spec.ts | 194 ++++++++++++++++-- .../src/modules/tldraw/controller/index.ts | 1 + .../controller/tldraw.controller.spec.ts | 53 ----- .../modules/tldraw/controller/tldraw.ws.ts | 62 +++++- .../tldraw/domain/ws-shared-doc.do.spec.ts | 2 +- .../modules/tldraw/factory/tldraw.factory.ts | 1 + .../websocket-close-error.loggable.spec.ts | 22 ++ .../websocket-close-error.loggable.ts | 13 ++ apps/server/src/modules/tldraw/repo/index.ts | 1 + .../modules/tldraw/repo/tldraw-board.repo.ts | 6 +- .../modules/tldraw/repo/tldraw.repo.spec.ts | 14 +- .../src/modules/tldraw/repo/tldraw.repo.ts | 8 +- .../src/modules/tldraw/service/index.ts | 1 + .../tldraw/service/tldraw.service.spec.ts | 8 +- .../tldraw/service/tldraw.ws.service.spec.ts | 72 ++++++- .../tldraw/service/tldraw.ws.service.ts | 21 +- .../modules/tldraw/testing/test-connection.ts | 6 +- .../src/modules/tldraw/tldraw-test.module.ts | 37 ++-- .../modules/tldraw/tldraw-ws-test.module.ts | 5 +- .../src/modules/tldraw/tldraw-ws.module.ts | 5 +- apps/server/src/modules/tldraw/types/index.ts | 2 +- .../tldraw/types/ws-close-code-enum.ts | 3 - .../src/modules/tldraw/types/ws-close-enum.ts | 12 ++ .../src/shared/domain/entity/all-entities.ts | 2 + config/default.schema.json | 1 + 31 files changed, 709 insertions(+), 132 deletions(-) create mode 100644 apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts create mode 100644 apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts delete mode 100644 apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts delete mode 100644 apps/server/src/modules/tldraw/types/ws-close-code-enum.ts create mode 100644 apps/server/src/modules/tldraw/types/ws-close-enum.ts diff --git a/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts b/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts new file mode 100644 index 00000000000..25d05911069 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts @@ -0,0 +1,133 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { + TestApiClient, + UserAndAccountTestFactory, + cardNodeFactory, + cleanupCollections, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, +} from '@shared/testing'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; + +const baseRouteName = '/elements'; +describe('drawing permission check (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when user is a valid teacher who is part of course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const drawingItemNode = drawingElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, drawingItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, teacherUser, columnBoardNode, columnNode, cardNode, drawingItemNode }; + }; + + it('should return status 200', async () => { + const { loggedInClient, drawingItemNode } = await setup(); + + const response = await loggedInClient.get(`${drawingItemNode.id}/permission`); + + expect(response.status).toEqual(200); + }); + }); + + describe('when only teacher is part of course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const course = courseFactory.build({ students: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course, studentAccount, studentUser]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const drawingItemNode = drawingElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, drawingItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser, columnBoardNode, columnNode, cardNode, drawingItemNode }; + }; + + it('should return status 403 for student not assigned to course', async () => { + const { loggedInClient, drawingItemNode } = await setup(); + + const response = await loggedInClient.get(`${drawingItemNode.id}/permission`); + + expect(response.status).toEqual(403); + }); + }); + + describe('when asking for non-existing resource', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([teacherAccount, teacherUser]); + + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return status 404 for wrong id', async () => { + const { loggedInClient } = await setup(); + const wrongRandomId = '655b048616056135293d1e63'; + + const response = await loggedInClient.get(`${wrongRandomId}/permission`); + + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 73eb9848774..2f766ea1d5b 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -4,6 +4,7 @@ import { Controller, Delete, ForbiddenException, + Get, HttpCode, NotFoundException, Param, @@ -141,4 +142,17 @@ export class ElementController { return response; } + + @ApiOperation({ summary: 'Check if user has read permission for any board element.' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Get(':contentElementId/permission') + async readPermission( + @Param() urlParams: ContentElementUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.elementUc.checkElementReadPermission(currentUser.userId, urlParams.contentElementId); + } } diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index cb2e7846118..f520fc6444d 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,9 +3,11 @@ import { Action, AuthorizationService } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; import { InputFormat } from '@shared/domain/types'; import { + cardFactory, + columnBoardFactory, drawingElementFactory, fileElementFactory, richTextElementFactory, @@ -79,7 +81,7 @@ describe(ElementUc.name, () => { const richTextElement = richTextElementFactory.build(); const content = { text: 'this has been updated', inputFormat: InputFormat.RICH_TEXT_CK5 }; - const elementSpy = elementService.findById.mockResolvedValue(richTextElement); + const elementSpy = elementService.findById.mockResolvedValueOnce(richTextElement); return { richTextElement, user, content, elementSpy }; }; @@ -107,7 +109,7 @@ describe(ElementUc.name, () => { const fileElement = fileElementFactory.build(); const content = { caption: 'this has been updated', alternativeText: 'this altText has been updated' }; - const elementSpy = elementService.findById.mockResolvedValue(fileElement); + const elementSpy = elementService.findById.mockResolvedValueOnce(fileElement); return { fileElement, user, content, elementSpy }; }; @@ -225,7 +227,7 @@ describe(ElementUc.name, () => { const user = userFactory.build(); const fileElement = fileElementFactory.build(); - elementService.findById.mockResolvedValue(fileElement); + elementService.findById.mockResolvedValueOnce(fileElement); return { fileElement, user }; }; @@ -246,7 +248,7 @@ describe(ElementUc.name, () => { const submissionContainer = submissionContainerElementFactory.build({ children: [fileElement] }); - elementService.findById.mockResolvedValue(submissionContainer); + elementService.findById.mockResolvedValueOnce(submissionContainer); return { submissionContainer, fileElement, user }; }; @@ -267,7 +269,7 @@ describe(ElementUc.name, () => { const submissionItem = submissionItemFactory.build({ userId: user.id }); const submissionContainer = submissionContainerElementFactory.build({ children: [submissionItem] }); - elementService.findById.mockResolvedValue(submissionContainer); + elementService.findById.mockResolvedValueOnce(submissionContainer); return { submissionContainer, submissionItem, user }; }; @@ -281,4 +283,56 @@ describe(ElementUc.name, () => { }); }); }); + + describe('checkElementReadPermission', () => { + const setup = () => { + const user = userFactory.build(); + const drawingElement = drawingElementFactory.build(); + const card = cardFactory.build({ children: [drawingElement] }); + const columnBoard = columnBoardFactory.build({ children: [card] }); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + id: columnBoard.id, + }); + + boardDoAuthorizableService.findById.mockResolvedValueOnce(authorizableMock); + + return { drawingElement, user }; + }; + + it('should properly find the element', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockResolvedValueOnce(drawingElement); + + await uc.checkElementReadPermission(user.id, drawingElement.id); + + expect(elementService.findById).toHaveBeenCalledWith(drawingElement.id); + }); + + it('should properly check element permission and not throw', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockResolvedValueOnce(drawingElement); + + await expect(uc.checkElementReadPermission(user.id, drawingElement.id)).resolves.not.toThrow(); + }); + + it('should throw at find element by Id', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockRejectedValueOnce(new Error()); + + await expect(uc.checkElementReadPermission(user.id, drawingElement.id)).rejects.toThrow(); + }); + + it('should throw at check permission', async () => { + const { user } = setup(); + const testElementId = 'wrongTestId123'; + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new Error(); + }); + + await expect(uc.checkElementReadPermission(user.id, testElementId)).rejects.toThrow(); + }); + }); }); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index b9043cd71f0..a7f978a1fb3 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -60,6 +60,11 @@ export class ElementUc extends BaseUc { return element; } + async checkElementReadPermission(userId: EntityId, elementId: EntityId): Promise { + const element = await this.elementService.findById(elementId); + await this.checkPermission(userId, element, Action.read); + } + async createSubmissionItem( userId: EntityId, contentElementId: EntityId, diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index a892ee6c843..50219da6516 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -10,6 +10,7 @@ export interface TldrawConfig { FEATURE_TLDRAW_ENABLED: boolean; TLDRAW_PING_TIMEOUT: number; TLDRAW_GC_ENABLED: number; + API_HOST: number; } const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; @@ -24,6 +25,7 @@ const tldrawConfig = { CONNECTION_STRING: tldrawConnectionString, TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, + API_HOST: Configuration.get('API_HOST') as string, }; export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; 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 new file mode 100644 index 00000000000..6d04d1d5871 --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts @@ -0,0 +1,69 @@ +import { INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { cleanupCollections, courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ServerTestModule } from '@modules/server'; +import { Logger } from '@src/core/logger'; +import { TldrawService } from '../../service'; +import { TldrawController } from '..'; +import { TldrawRepo } from '../../repo'; +import { tldrawEntityFactory } from '../../factory'; + +const baseRouteName = '/tldraw-document'; +describe('tldraw controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + controllers: [TldrawController], + providers: [Logger, TldrawService, TldrawRepo], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('with valid user', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const drawingItemData = tldrawEntityFactory.build(); + + await em.persistAndFlush([drawingItemData]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, teacherUser, drawingItemData }; + }; + + it('should return status 200 for delete', async () => { + const { loggedInClient, drawingItemData } = await setup(); + + const response = await loggedInClient.delete(`${drawingItemData.docName}`); + + expect(response.status).toEqual(204); + }); + + it('should return status 404 for delete with wrong id', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.delete(`testID123`); + + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts index ade447b127c..6a0a3cc4fc3 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -3,14 +3,22 @@ import { Test } from '@nestjs/testing'; import WebSocket from 'ws'; import { TextEncoder } from 'util'; import { INestApplication } from '@nestjs/common'; -import { TldrawWsTestModule } from '@src/modules/tldraw/tldraw-ws-test.module'; -import { TldrawWs } from '../tldraw.ws'; +import { throwError } from 'rxjs'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { AxiosError, AxiosRequestHeaders } from 'axios'; +import { WsCloseCodeEnum, WsCloseMessageEnum } from '../../types'; +import { TldrawWsTestModule } from '../../tldraw-ws-test.module'; +import { TldrawWsService } from '../../service'; import { TestConnection } from '../../testing/test-connection'; +import { TldrawWs } from '../tldraw.ws'; describe('WebSocketController (WsAdapter)', () => { let app: INestApplication; let gateway: TldrawWs; let ws: WebSocket; + let wsService: TldrawWsService; + let httpService: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -21,8 +29,16 @@ describe('WebSocketController (WsAdapter)', () => { beforeAll(async () => { const testingModule = await Test.createTestingModule({ imports: [TldrawWsTestModule], + providers: [ + { + provide: HttpService, + useValue: createMock(), + }, + ], }).compile(); gateway = testingModule.get(TldrawWs); + wsService = testingModule.get(TldrawWsService); + httpService = testingModule.get(HttpService); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); await app.init(); @@ -40,7 +56,7 @@ describe('WebSocketController (WsAdapter)', () => { jest.clearAllMocks(); }); - describe('when tldraw is correctly setup', () => { + describe('when tldraw connection is established', () => { const setup = async () => { const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); @@ -52,16 +68,17 @@ describe('WebSocketController (WsAdapter)', () => { return { handleConnectionSpy, buffer }; }; - it(`should handle connection and data transfer`, async () => { + it(`should handle connection`, async () => { const { handleConnectionSpy, buffer } = await setup(); ws.send(buffer, () => {}); expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + handleConnectionSpy.mockRestore(); ws.close(); }); it(`check if client will receive message`, async () => { - const { buffer } = await setup(); + const { handleConnectionSpy, buffer } = await setup(); ws.send(buffer, () => {}); gateway.server.on('connection', (client) => { @@ -70,6 +87,7 @@ describe('WebSocketController (WsAdapter)', () => { }); }); + handleConnectionSpy.mockRestore(); ws.close(); }); }); @@ -77,8 +95,8 @@ describe('WebSocketController (WsAdapter)', () => { describe('when tldraw doc has multiple clients', () => { const setup = async () => { const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); - ws = await TestConnection.setupWs(wsUrl, 'TEST2'); - const ws2 = await TestConnection.setupWs(wsUrl, 'TEST2'); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const ws2 = await TestConnection.setupWs(wsUrl, 'TEST'); const { buffer } = getMessage(); @@ -97,32 +115,174 @@ describe('WebSocketController (WsAdapter)', () => { expect(handleConnectionSpy).toHaveBeenCalled(); expect(handleConnectionSpy).toHaveBeenCalledTimes(2); + handleConnectionSpy.mockRestore(); ws.close(); ws2.close(); }); }); - describe('when tldraw is not correctly setup', () => { - const setup = async () => { - const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + describe('when checking cookie', () => { + const setup = () => { + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); + + return { + httpGetCallSpy, + wsCloseSpy, + }; + }; + + it(`should refuse connection if there is no jwt in cookie`, async () => { + const { httpGetCallSpy, wsCloseSpy } = setup(); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', {}); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + httpGetCallSpy.mockRestore(); + wsCloseSpy.mockRestore(); + ws.close(); + }); - ws = await TestConnection.setupWs(wsUrl); + it(`should refuse connection if jwt is wrong`, async () => { + const { wsCloseSpy, httpGetCallSpy } = setup(); + const error = new Error('unknown error'); + + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + httpGetCallSpy.mockRestore(); + wsCloseSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when checking docName and cookie', () => { + const setup = () => { + const setupConnectionSpy = jest.spyOn(wsService, 'setupWSConnection'); + const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); return { - handleConnectionSpy, + setupConnectionSpy, + wsCloseSpy, }; }; - it(`should refuse connection if there is no docName`, async () => { - const { handleConnectionSpy } = await setup(); + it(`should close for existing cookie and not existing docName`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + ws = await TestConnection.setupWs(wsUrl, '', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, + WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE + ); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + ws.close(); + }); + it(`should close for not existing docName resource`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const authorizeConnectionSpy = jest.spyOn(wsService, 'authorizeConnection'); + authorizeConnectionSpy.mockImplementationOnce(() => { + throw new AxiosError('Resource not found', '404', undefined, undefined, { + config: { headers: {} as AxiosRequestHeaders }, + data: undefined, + request: undefined, + statusText: '', + status: 404, + headers: {}, + }); + }); + ws = await TestConnection.setupWs(wsUrl, 'GLOBAL', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, + WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE + ); + + authorizeConnectionSpy.mockRestore(); + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + ws.close(); + }); + + it(`should close for not authorizing connection`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); + + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const error = new Error('unknown error'); + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); ws.send(buffer); - expect(gateway.server).toBeDefined(); - expect(handleConnectionSpy).toHaveBeenCalled(); - expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it(`should setup connection for proper data`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest + .spyOn(wsService, 'authorizeConnection') + .mockImplementationOnce(() => Promise.resolve()); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it(`should close after throw at setup connection`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest + .spyOn(wsService, 'authorizeConnection') + .mockImplementationOnce(() => Promise.resolve()); + setupConnectionSpy.mockImplementationOnce(() => { + throw new Error('unknown error'); + }); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE + ); + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); ws.close(); }); }); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts index 0b0cf7d103b..38a96a42a75 100644 --- a/apps/server/src/modules/tldraw/controller/index.ts +++ b/apps/server/src/modules/tldraw/controller/index.ts @@ -1 +1,2 @@ export * from './tldraw.ws'; +export * from './tldraw.controller'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts deleted file mode 100644 index 2528fd8c4d7..00000000000 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TldrawController } from './tldraw.controller'; -import { TldrawService } from '../service/tldraw.service'; -import { TldrawDeleteParams } from './tldraw.params'; - -describe('TldrawController', () => { - let module: TestingModule; - let controller: TldrawController; - let service: TldrawService; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - { - provide: TldrawService, - useValue: createMock(), - }, - ], - controllers: [TldrawController], - }).compile(); - - controller = module.get(TldrawController); - service = module.get(TldrawService); - }); - - afterAll(async () => { - await module.close(); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('delete', () => { - describe('when task should be copied via API call', () => { - const setup = () => { - const params: TldrawDeleteParams = { - docName: 'test-name', - }; - - const ucSpy = jest.spyOn(service, 'deleteByDocName').mockImplementation(() => Promise.resolve()); - return { params, ucSpy }; - }; - - it('should call service with parentIds', async () => { - const { params, ucSpy } = setup(); - await controller.deleteByDocName(params); - expect(ucSpy).toHaveBeenCalledWith('test-name'); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts index 343997b2aba..4aad2c404b3 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -1,8 +1,14 @@ import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; import { Server, WebSocket } from 'ws'; +import { Request } from 'express'; import { ConfigService } from '@nestjs/config'; +import cookie from 'cookie'; +import { BadRequestException } from '@nestjs/common'; +import { Logger } from '@src/core/logger'; +import { AxiosError } from 'axios'; +import { WebsocketCloseErrorLoggable } from '../loggable/websocket-close-error.loggable'; import { TldrawConfig, SOCKET_PORT } from '../config'; -import { WsCloseCodeEnum } from '../types'; +import { WsCloseCodeEnum, WsCloseMessageEnum } from '../types'; import { TldrawWsService } from '../service'; @WebSocketGateway(SOCKET_PORT) @@ -12,18 +18,50 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { constructor( private readonly configService: ConfigService, - private readonly tldrawWsService: TldrawWsService + private readonly tldrawWsService: TldrawWsService, + private readonly logger: Logger ) {} - public handleConnection(client: WebSocket, request: Request): void { + async handleConnection(client: WebSocket, request: Request): Promise { const docName = this.getDocNameFromRequest(request); - if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { - this.tldrawWsService.setupWSConnection(client, docName); + const cookies = this.parseCookiesFromHeader(request); + try { + await this.tldrawWsService.authorizeConnection(docName, cookies?.jwt); + } catch (err) { + if ((err as AxiosError).response?.status === 404 || (err as AxiosError).response?.status === 400) { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, + WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE, + err as Error + ); + } else { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE, + err as Error + ); + } + return; + } + try { + this.tldrawWsService.setupWSConnection(client, docName); + } catch (err) { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE, + err as Error + ); + } } else { - client.close( + this.closeClientAndLogError( + client, WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, - 'Document name is mandatory in url or Tldraw Tool is turned off.' + WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE, + new BadRequestException() ); } } @@ -45,4 +83,14 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); return urlStripped; } + + private parseCookiesFromHeader(request: Request): { [p: string]: string } { + const parsedCookies: { [p: string]: string } = cookie.parse(request.headers.cookie || ''); + return parsedCookies; + } + + private closeClientAndLogError(client: WebSocket, code: WsCloseCodeEnum, data: string, err: Error): void { + client.close(code, data); + this.logger.warning(new WebsocketCloseErrorLoggable(err, `(${code}) ${data}`)); + } } diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts index 78cf9ea9428..7b0b0d8c60c 100644 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -51,7 +51,7 @@ describe('WsSharedDocDo', () => { describe('ydoc client awareness change handler', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); class MockAwareness { on = jest.fn(); diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts index 3cb63e9418b..c6e80ec2329 100644 --- a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts +++ b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts @@ -6,6 +6,7 @@ export const tldrawEntityFactory = BaseFactory.define { return { _id: 'test-id', + id: 'test-id', docName: 'test-name', value: 'test-value', version: `test-version-${sequence}`, diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts new file mode 100644 index 00000000000..ba0b21c9714 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts @@ -0,0 +1,22 @@ +import { WebsocketCloseErrorLoggable } from './websocket-close-error.loggable'; + +describe('WebsocketCloseErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const errorMessage = 'message'; + + const loggable = new WebsocketCloseErrorLoggable(error, errorMessage); + + return { loggable, error, errorMessage }; + }; + + it('should return a loggable message', () => { + const { loggable, error, errorMessage } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ message: errorMessage, error, type: 'WEBSOCKET_CLOSE_ERROR' }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts new file mode 100644 index 00000000000..6da84c3699f --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts @@ -0,0 +1,13 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketCloseErrorLoggable implements Loggable { + constructor(private readonly error: Error, private readonly message: string) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: this.message, + type: 'WEBSOCKET_CLOSE_ERROR', + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts index 0c1ae29e62f..3cc9ad02bf7 100644 --- a/apps/server/src/modules/tldraw/repo/index.ts +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -1 +1,2 @@ export * from './tldraw-board.repo'; +export * from './tldraw.repo'; diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts index ce3a124f7f0..8f3b3187158 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -26,9 +26,9 @@ export class TldrawBoardRepo { // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment this.mdb = new MongodbPersistence(this.connectionString, { - collectionName: this.collectionName, - flushSize: this.flushSize, - multipleCollections: this.multipleCollections, + collectionName: 'drawings', + flushSize: 400, + multipleCollections: false, }); } diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts index 9e6f5eabb14..5c075669431 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -1,9 +1,10 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections } from '@shared/testing'; -import { tldrawEntityFactory } from '@src/modules/tldraw/factory'; -import { TldrawDrawing } from '@src/modules/tldraw/entities'; import { MongoMemoryDatabaseModule } from '@infra/database'; +import { NotFoundException } from '@nestjs/common'; +import { tldrawEntityFactory } from '../factory'; +import { TldrawDrawing } from '../entities'; import { TldrawRepo } from './tldraw.repo'; describe(TldrawRepo.name, () => { @@ -68,9 +69,8 @@ describe(TldrawRepo.name, () => { expect(result[0]._id).toEqual(drawing._id); }); - it('should not find any record giving wrong docName', async () => { - const result = await repo.findByDocName('invalid-name'); - expect(result.length).toEqual(0); + it('should throw NotFoundException for wrong docName', async () => { + await expect(repo.findByDocName('invalid-name')).rejects.toThrow(NotFoundException); }); }); }); @@ -84,8 +84,8 @@ describe(TldrawRepo.name, () => { const results = await repo.findByDocName(drawing.docName); await repo.delete(results); - const emptyResults = await repo.findByDocName(drawing.docName); - expect(emptyResults.length).toEqual(0); + expect(results.length).not.toEqual(0); + await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); }); }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts index d826b2876ff..d8eb4330bd2 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -1,5 +1,5 @@ import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; @Injectable() @@ -11,7 +11,11 @@ export class TldrawRepo { } async findByDocName(docName: string): Promise { - return this._em.find(TldrawDrawing, { docName }); + const domainObject = await this._em.find(TldrawDrawing, { docName }); + if (domainObject.length === 0) { + throw new NotFoundException(`There is no '${docName}' for this docName`); + } + return domainObject; } async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts index a056b2ece10..2bc9f981432 100644 --- a/apps/server/src/modules/tldraw/service/index.ts +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -1 +1,2 @@ export * from './tldraw.ws.service'; +export * from './tldraw.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts index cc3a317ec3c..546ab739bb0 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; +import { NotFoundException } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; import { tldrawEntityFactory } from '../factory'; import { TldrawRepo } from '../repo/tldraw.repo'; @@ -44,9 +45,12 @@ describe(TldrawService.name, () => { expect(result.length).toEqual(1); await service.deleteByDocName(drawing.docName); - const emptyResult = await repo.findByDocName(drawing.docName); - expect(emptyResult.length).toEqual(0); + await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); + }); + + it('should throw when cannot find drawing', async () => { + await expect(service.deleteByDocName('nonExistingName')).rejects.toThrow(NotFoundException); }); }); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts index 1199bf217cc..04ac871d428 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -10,6 +10,11 @@ import * as SyncProtocols from 'y-protocols/sync'; import * as AwarenessProtocol from 'y-protocols/awareness'; import { encoding } from 'lib0'; import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; +import { HttpService } from '@nestjs/axios'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of, throwError } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { axiosResponseFactory } from '@shared/testing'; import { MetricsService } from '@modules/tldraw/metrics'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; import { config } from '../config'; @@ -37,6 +42,7 @@ describe('TldrawWSService', () => { let app: INestApplication; let ws: WebSocket; let service: TldrawWsService; + let httpService: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -52,10 +58,20 @@ describe('TldrawWSService', () => { const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ imports, - providers: [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService], + providers: [ + TldrawWs, + TldrawBoardRepo, + TldrawWsService, + MetricsService, + { + provide: HttpService, + useValue: createMock(), + }, + ], }).compile(); service = testingModule.get(TldrawWsService); + httpService = testingModule.get(HttpService); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); @@ -88,7 +104,7 @@ describe('TldrawWSService', () => { describe('send', () => { describe('when client is not connected to WS', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); @@ -152,7 +168,7 @@ describe('TldrawWSService', () => { describe('when websocket has ready state 0', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const sendSpy = jest.spyOn(service, 'send'); @@ -447,4 +463,54 @@ describe('TldrawWSService', () => { flushDocumentSpy.mockRestore(); }); }); + + describe('authorizeConnection', () => { + it('should call properly method', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.get.mockReturnValueOnce(of(response)); + + await expect(service.authorizeConnection(params.drawingName, params.token)).resolves.not.toThrow(); + httpService.get.mockRestore(); + }); + + it('should properly setup REST GET call params', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + const expectedUrl = 'http://localhost:3030/api/v3/elements/drawingName/permission'; + const expectedHeaders = { + headers: { + Accept: 'Application/json', + Authorization: `Bearer ${params.token}`, + }, + }; + httpService.get.mockReturnValueOnce(of(response)); + + await service.authorizeConnection(params.drawingName, params.token); + + expect(httpService.get).toHaveBeenCalledWith(expectedUrl, expectedHeaders); + httpService.get.mockRestore(); + }); + + it('should throw error for http response', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const error = new Error('unknown error'); + httpService.get.mockReturnValueOnce(throwError(() => error)); + + await expect(service.authorizeConnection(params.drawingName, params.token)).rejects.toThrow(); + httpService.get.mockRestore(); + }); + + it('should throw error for lack of token', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + + await expect(service.authorizeConnection(params.drawingName, '')).rejects.toThrow(); + httpService.get.mockRestore(); + }); + }); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts index ff455454d8b..f1ef8744c44 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -1,9 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import WebSocket from 'ws'; import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; import { encoding, decoding, map } from 'lib0'; import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'; +import { firstValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; import { Persitence, WSConnectionState, WSMessageType } from '../types'; import { TldrawConfig } from '../config'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; @@ -21,6 +23,7 @@ export class TldrawWsService { constructor( private readonly configService: ConfigService, private readonly tldrawBoardRepo: TldrawBoardRepo, + private readonly httpService: HttpService, private readonly metricsService: MetricsService ) { this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); @@ -212,4 +215,20 @@ export class TldrawWsService { public async flushDocument(docName: string): Promise { await this.tldrawBoardRepo.flushDocument(docName); } + + public async authorizeConnection(drawingName: string, token: string) { + if (!token) { + throw new UnauthorizedException('Token was not given'); + } + const headers = { + Accept: 'Application/json', + Authorization: `Bearer ${token}`, + }; + + await firstValueFrom( + this.httpService.get(`${this.configService.get('API_HOST')}/v3/elements/${drawingName}/permission`, { + headers, + }) + ); + } } diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts index 638c219ea18..4231acbb286 100644 --- a/apps/server/src/modules/tldraw/testing/test-connection.ts +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -6,12 +6,12 @@ export class TestConnection { return wsUrl; }; - public static setupWs = async (wsUrl: string, docName?: string): Promise => { + public static setupWs = async (wsUrl: string, docName?: string, headers?: object): Promise => { let ws: WebSocket; if (docName) { - ws = new WebSocket(`${wsUrl}/${docName}`); + ws = new WebSocket(`${wsUrl}/${docName}`, headers); } else { - ws = new WebSocket(`${wsUrl}`); + ws = new WebSocket(`${wsUrl}`, headers); } await new Promise((resolve) => { ws.on('open', resolve); diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts index 3e3cd60396e..59c8af72f74 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -1,27 +1,25 @@ import { DynamicModule, Module } from '@nestjs/common'; import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; -import { CoreModule } from '@src/core'; -import { LoggerModule } from '@src/core/logger'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { AuthorizationModule } from '@modules/authorization'; -import { Course, User } from '@shared/domain/entity'; -import { MetricsService } from '@modules/tldraw/metrics'; -import { AuthenticationApiModule } from '../authentication/authentication-api.module'; -import { TldrawWsModule } from './tldraw-ws.module'; -import { TldrawWs } from './controller'; -import { TldrawBoardRepo } from './repo'; -import { TldrawWsService } from './service'; +import { Logger, LoggerModule } from '@src/core/logger'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { RedisModule } from '@infra/redis'; +import { defaultMikroOrmOptions } from '@modules/server'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; +import { config } from './config'; +import { TldrawController } from './controller/tldraw.controller'; +import { TldrawService } from './service/tldraw.service'; +import { TldrawRepo } from './repo/tldraw.repo'; const imports = [ - TldrawWsModule, - MongoMemoryDatabaseModule.forRoot({ entities: [User, Course] }), - AuthenticationApiModule, - AuthorizationModule, - AuthenticationModule, - CoreModule, + MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), LoggerModule, + ConfigModule.forRoot(createConfigModuleOptions(config)), + RedisModule, + HttpModule, ]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; +const providers = [Logger, TldrawService, TldrawRepo, MetricsService]; @Module({ imports, providers, @@ -30,7 +28,8 @@ export class TldrawTestModule { static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { return { module: TldrawTestModule, - imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions, ...options })], + controllers: [TldrawController], providers, }; } diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts index 815f09cbccd..7a80aac20de 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -3,13 +3,14 @@ import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/da import { CoreModule } from '@src/core'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; -import { MetricsService } from '@modules/tldraw/metrics'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { config } from './config'; import { TldrawWs } from './controller'; -const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; +const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule]; const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; @Module({ imports, diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 183c579296f..8ed614a510e 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -3,14 +3,15 @@ import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; -import { MetricsService } from '@modules/tldraw/metrics'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { TldrawWs } from './controller'; import { config } from './config'; @Module({ - imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule], providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo, MetricsService], }) export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts index 0579e4b8c79..957e55aab3f 100644 --- a/apps/server/src/modules/tldraw/types/index.ts +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -1,3 +1,3 @@ export * from './connection-enum'; -export * from './ws-close-code-enum'; +export * from './ws-close-enum'; export * from './persistence-type'; diff --git a/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts deleted file mode 100644 index 274fa99a6ae..00000000000 --- a/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum WsCloseCodeEnum { - WS_CLIENT_BAD_REQUEST_CODE = 4400, -} diff --git a/apps/server/src/modules/tldraw/types/ws-close-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-enum.ts new file mode 100644 index 00000000000..0cbf8021e84 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/ws-close-enum.ts @@ -0,0 +1,12 @@ +export enum WsCloseCodeEnum { + WS_CLIENT_BAD_REQUEST_CODE = 4400, + WS_CLIENT_UNAUTHORISED_CONNECTION_CODE = 4401, + WS_CLIENT_NOT_FOUND_CODE = 4404, + WS_CLIENT_ESTABLISHING_CONNECTION_CODE = 4500, +} +export enum WsCloseMessageEnum { + WS_CLIENT_BAD_REQUEST_MESSAGE = 'Document name is mandatory in url or Tldraw Tool is turned off.', + WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE = "Unauthorised connection - you don't have permission to this drawing.", + WS_CLIENT_NOT_FOUND_MESSAGE = 'Drawing not found.', + WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE = 'Unable to establish websocket connection. Try again later.', +} diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 94d38d08346..7163ffb9f12 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -9,6 +9,7 @@ import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { DeletionLogEntity, DeletionRequestEntity } from '@src/modules/deletion/entity'; import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; +import { TldrawDrawing } from '@modules/tldraw/entities'; import { Account } from './account.entity'; import { BoardNode, @@ -112,4 +113,5 @@ export const ALL_ENTITIES = [ VideoConference, GroupEntity, RegistrationPinEntity, + TldrawDrawing, ]; diff --git a/config/default.schema.json b/config/default.schema.json index 9774124f911..c11a49968ba 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -534,6 +534,7 @@ "API_HOST": { "type": "string", "format": "uri", + "default": "http://localhost:3030/api", "pattern": ".*(? Date: Fri, 12 Jan 2024 13:18:02 +0100 Subject: [PATCH 25/25] N21-1507 copy ctl tools (#4681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * copy ctl tools in tab and board when course copy * change validation of context and school external tool --------- Co-authored-by: Marvin Öhlerking Co-authored-by: Arne Gnisa --- apps/server/src/modules/board/board.module.ts | 2 + .../board-do-copy.service.spec.ts | 234 +++++- .../board-do-copy.service.ts | 15 +- .../recursive-copy.visitor.spec.ts | 29 +- .../recursive-copy.visitor.ts | 33 +- .../modules/copy-helper/types/copy.types.ts | 69 +- .../src/modules/learnroom/learnroom.module.ts | 12 +- .../service/course-copy.service.spec.ts | 95 +++ .../learnroom/service/course-copy.service.ts | 39 +- ...rnal-tool-configuration-status.response.ts | 7 + ...text-external-tool-configuration-status.ts | 3 + .../modules/tool/common/domain/error/index.ts | 6 + ...meter-duplicate.loggable-exception.spec.ts | 20 + ...-parameter-duplicate.loggable-exception.ts | 19 + ...ameter-required.loggable-exception.spec.ts | 33 + ...l-parameter-required.loggable-exception.ts | 22 + ...r-type-mismatch.loggable-exception.spec.ts | 36 + ...ameter-type-mismatch.loggable-exception.ts | 23 + ...rameter-unknown.loggable-exception.spec.ts | 35 + ...ol-parameter-unknown.loggable-exception.ts | 20 + ...r-value-missing.loggable-exception.spec.ts | 35 + ...ameter-value-missing.loggable-exception.ts | 20 + ...ter-value-regex.loggable-exception.spec.ts | 35 + ...arameter-value-regex.loggable-exception.ts | 22 + .../src/modules/tool/common/domain/index.ts | 8 + .../mapper/tool-status-response.mapper.ts | 1 + .../common-tool-validation.service.spec.ts | 733 ------------------ .../service/common-tool-validation.service.ts | 114 --- .../common/service/common-tool.service.ts | 5 +- .../src/modules/tool/common/service/index.ts | 2 +- .../common-tool-validation.service.spec.ts | 159 ++++ .../common-tool-validation.service.ts | 42 + .../tool/common/service/validation/index.ts | 2 + .../common/service/validation/rules/index.ts | 8 + ...eter-array-duplicate-key-validator.spec.ts | 57 ++ ...parameter-array-duplicate-key-validator.ts | 22 + .../parameter-array-entry-validator.spec.ts | 90 +++ .../rules/parameter-array-entry-validator.ts | 37 + ...ameter-array-unknown-key-validator.spec.ts | 60 ++ .../parameter-array-unknown-key-validator.ts | 21 + .../rules/parameter-array-validator.ts | 6 + .../parameter-entry-regex-validator.spec.ts | 60 ++ .../rules/parameter-entry-regex-validator.ts | 13 + .../parameter-entry-type-validator.spec.ts | 61 ++ .../rules/parameter-entry-type-validator.ts | 17 + .../rules/parameter-entry-validator.ts | 6 + .../parameter-entry-value-validator.spec.ts | 82 ++ .../rules/parameter-entry-value-validator.ts | 13 + ...ool-parameter-type-validation.util.spec.ts | 99 +++ .../tool-parameter-type-validation.util.ts | 22 + .../api-test/tool-context.api.spec.ts | 4 +- ...t-external-tool-validation.service.spec.ts | 38 +- ...ontext-external-tool-validation.service.ts | 9 +- .../context-external-tool.service.spec.ts | 98 +++ .../service/context-external-tool.service.ts | 57 ++ .../service/tool-reference.service.spec.ts | 2 +- .../service/tool-reference.service.ts | 13 +- .../service/tool-version-service.spec.ts | 276 ++++--- .../service/tool-version-service.ts | 45 +- ...-tool-parameter-validation.service.spec.ts | 5 - ...ernal-tool-parameter-validation.service.ts | 9 +- .../api-test/tool-school.api.spec.ts | 4 +- ...l-external-tool-validation.service.spec.ts | 96 ++- ...school-external-tool-validation.service.ts | 9 +- apps/server/src/modules/tool/tool-config.ts | 2 + .../service/tool-launch.service.spec.ts | 9 +- .../service/tool-launch.service.ts | 17 +- ...l-configuration-status-response.factory.ts | 1 + .../tool/tool-configuration-status.factory.ts | 1 + backup/setup/context-external-tools.json | 51 +- backup/setup/external-tools.json | 37 + backup/setup/migrations.json | 11 + backup/setup/school-external-tools.json | 23 + config/default.schema.json | 5 + ...undefined-parameters-from-external-tool.js | 82 ++ src/services/config/publicAppConfigService.js | 1 + 76 files changed, 2341 insertions(+), 1168 deletions(-) create mode 100644 apps/server/src/modules/tool/common/domain/error/index.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.ts delete mode 100644 apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts delete mode 100644 apps/server/src/modules/tool/common/service/common-tool-validation.service.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/index.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/index.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-array-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts create mode 100644 apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts create mode 100644 migrations/1704369994725-remove-undefined-parameters-from-external-tool.js diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 1c66d759695..514d6fc1f19 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -8,6 +8,7 @@ import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { HttpModule } from '@nestjs/axios'; +import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; import { BoardDoAuthorizableService, @@ -29,6 +30,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; UserModule, ContextExternalToolModule, HttpModule, + ToolConfigModule, ], providers: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index ecebe7ac65a..b41e7d3e811 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -1,6 +1,8 @@ -import { createMock } from '@golevelup/ts-jest'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { FileRecordParentType } from '@infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Test, TestingModule } from '@nestjs/testing'; import { Card, @@ -9,9 +11,6 @@ import { DrawingElement, ExternalToolElement, FileElement, - LinkElement, - RichTextElement, - SubmissionContainerElement, isCard, isColumn, isColumnBoard, @@ -21,11 +20,15 @@ import { isLinkElement, isRichTextElement, isSubmissionContainerElement, + LinkElement, + RichTextElement, + SubmissionContainerElement, } from '@shared/domain/domainobject'; import { cardFactory, columnBoardFactory, columnFactory, + contextExternalToolFactory, drawingElementFactory, externalToolElementFactory, fileElementFactory, @@ -36,6 +39,7 @@ import { submissionItemFactory, } from '@shared/testing'; import { ObjectId } from 'bson'; +import { ToolFeatures } from '@modules/tool/tool-config'; import { BoardDoCopyService } from './board-do-copy.service'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; @@ -43,16 +47,35 @@ describe('recursive board copy visitor', () => { let module: TestingModule; let service: BoardDoCopyService; + let contextExternalToolService: DeepMocked; + beforeAll(async () => { module = await Test.createTestingModule({ - providers: [BoardDoCopyService], + providers: [ + BoardDoCopyService, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + ctlToolsCopyEnabled: true, + }, + }, + ], }).compile(); service = module.get(BoardDoCopyService); + contextExternalToolService = module.get(ContextExternalToolService); await setupEntities(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + const setupfileCopyService = () => { const fileCopyService = createMock(); @@ -711,61 +734,192 @@ describe('recursive board copy visitor', () => { }); }); - describe('when copying a external tool element', () => { - const setup = () => { - const original = externalToolElementFactory.build(); + describe('when copying an external tool element', () => { + describe('when the element has no linked tool', () => { + const setup = () => { + const original = externalToolElementFactory.build(); - return { original, ...setupfileCopyService() }; - }; + return { original, ...setupfileCopyService() }; + }; - const getExternalToolElementFromStatus = (status: CopyStatus): ExternalToolElement => { - const copy = status.copyEntity; + const getExternalToolElementFromStatus = (status: CopyStatus): ExternalToolElement => { + const copy = status.copyEntity; - expect(isExternalToolElement(copy)).toEqual(true); + expect(isExternalToolElement(copy)).toEqual(true); - return copy as ExternalToolElement; - }; + return copy as ExternalToolElement; + }; - it('should return a external tool element as copy', async () => { - const { original, fileCopyService } = setup(); + it('should return a external tool element as copy', async () => { + const { original, fileCopyService } = setup(); - const result = await service.copy({ original, fileCopyService }); + const result = await service.copy({ original, fileCopyService }); - expect(isExternalToolElement(result.copyEntity)).toEqual(true); - }); + expect(isExternalToolElement(result.copyEntity)).toEqual(true); + }); - it('should not copy tool', async () => { - const { original, fileCopyService } = setup(); + it('should not copy tool', async () => { + const { original, fileCopyService } = setup(); - const result = await service.copy({ original, fileCopyService }); - const copy = getExternalToolElementFromStatus(result); + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); - expect(copy.contextExternalToolId).toBeUndefined(); - }); + expect(copy.contextExternalToolId).toBeUndefined(); + }); - it('should create new id', async () => { - const { original, fileCopyService } = setup(); + it('should create new id', async () => { + const { original, fileCopyService } = setup(); - const result = await service.copy({ original, fileCopyService }); - const copy = getExternalToolElementFromStatus(result); + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); - expect(copy.id).not.toEqual(original.id); - }); + expect(copy.id).not.toEqual(original.id); + }); - it('should show status successful', async () => { - const { original, fileCopyService } = setup(); + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); - const result = await service.copy({ original, fileCopyService }); + const result = await service.copy({ original, fileCopyService }); - expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type ExternalToolElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + }); }); - it('should show type RichTextElement', async () => { - const { original, fileCopyService } = setup(); + describe('when the element has a linked tool and the feature is active', () => { + describe('when the linked tool exists', () => { + const setup = () => { + const originalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + const copiedTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); - const result = await service.copy({ original, fileCopyService }); + const original: ExternalToolElement = externalToolElementFactory.build({ + contextExternalToolId: originalTool.id, + }); + + contextExternalToolService.findById.mockResolvedValueOnce(originalTool); + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(copiedTool); + + return { original, ...setupfileCopyService(), copiedTool }; + }; + + const getExternalToolElementFromStatus = (status: CopyStatus): ExternalToolElement => { + const copy = status.copyEntity; + + expect(isExternalToolElement(copy)).toEqual(true); + + return copy as ExternalToolElement; + }; + + it('should return a external tool element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isExternalToolElement(result.copyEntity)).toEqual(true); + }); + + it('should copy tool', async () => { + const { original, fileCopyService, copiedTool } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); - expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + expect(copy.contextExternalToolId).toEqual(copiedTool.id); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type ExternalToolElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + }); + }); + + describe('when the linked tool does not exist anymore', () => { + const setup = () => { + const original: ExternalToolElement = externalToolElementFactory.build({ + contextExternalToolId: new ObjectId().toHexString(), + }); + + contextExternalToolService.findById.mockResolvedValueOnce(null); + + return { original, ...setupfileCopyService() }; + }; + + const getExternalToolElementFromStatus = (status: CopyStatus): ExternalToolElement => { + const copy = status.copyEntity; + + expect(isExternalToolElement(copy)).toEqual(true); + + return copy as ExternalToolElement; + }; + + it('should return a external tool element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isExternalToolElement(result.copyEntity)).toEqual(true); + }); + + it('should not try to copy the tool', async () => { + const { original, fileCopyService } = setup(); + + await service.copy({ original, fileCopyService }); + + expect(contextExternalToolService.copyContextExternalTool).not.toHaveBeenCalled(); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status fail', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.FAIL); + }); + + it('should show type ExternalToolElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + }); + }); }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts index a398a807de9..f981653ec37 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts @@ -1,6 +1,8 @@ +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { CopyStatus } from '@modules/copy-helper'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { AnyBoardDo } from '@shared/domain/domainobject'; +import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { RecursiveCopyVisitor } from './recursive-copy.visitor'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; @@ -11,8 +13,17 @@ export type BoardDoCopyParams = { @Injectable() export class BoardDoCopyService { + constructor( + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly contextExternalToolService: ContextExternalToolService + ) {} + public async copy(params: BoardDoCopyParams): Promise { - const visitor = new RecursiveCopyVisitor(params.fileCopyService); + const visitor = new RecursiveCopyVisitor( + params.fileCopyService, + this.contextExternalToolService, + this.toolFeatures + ); const result = await visitor.copy(params.original); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts index 3fe6c7b6cbe..6cbea74cda3 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts @@ -3,27 +3,44 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LinkElement } from '@shared/domain/domainobject'; import { linkElementFactory, setupEntities } from '@shared/testing'; import { CopyFileDto } from '@src/modules/files-storage-client/dto'; - +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { RecursiveCopyVisitor } from './recursive-copy.visitor'; import { SchoolSpecificFileCopyServiceFactory } from './school-specific-file-copy-service.factory'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; describe(RecursiveCopyVisitor.name, () => { let module: TestingModule; + let fileCopyServiceFactory: DeepMocked; + let contextExternalToolService: DeepMocked; + + let toolFeatures: IToolFeatures; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ + RecursiveCopyVisitor, { provide: SchoolSpecificFileCopyServiceFactory, useValue: createMock(), }, - RecursiveCopyVisitor, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + ctlToolsCopyEnabled: true, + }, + }, ], }).compile(); fileCopyServiceFactory = module.get(SchoolSpecificFileCopyServiceFactory); + contextExternalToolService = module.get(ContextExternalToolService); + toolFeatures = module.get(ToolFeatures); await setupEntities(); }); @@ -57,7 +74,7 @@ describe(RecursiveCopyVisitor.name, () => { const { fileCopyServiceMock } = setup(); const linkElement = linkElementFactory.build(); - const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock, contextExternalToolService, toolFeatures); await visitor.visitLinkElementAsync(linkElement); @@ -70,7 +87,7 @@ describe(RecursiveCopyVisitor.name, () => { const { fileCopyServiceMock, imageUrl } = setup({ withFileCopy: true }); const linkElement = linkElementFactory.build({ imageUrl }); - const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock, contextExternalToolService, toolFeatures); await visitor.visitLinkElementAsync(linkElement); @@ -83,7 +100,7 @@ describe(RecursiveCopyVisitor.name, () => { const { fileCopyServiceMock, imageUrl, newFileId } = setup({ withFileCopy: true }); const linkElement = linkElementFactory.build({ imageUrl }); - const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock, contextExternalToolService, toolFeatures); await visitor.visitLinkElementAsync(linkElement); const copy = visitor.copyMap.get(linkElement.id) as LinkElement; @@ -97,7 +114,7 @@ describe(RecursiveCopyVisitor.name, () => { const { fileCopyServiceMock } = setup({ withFileCopy: true }); const linkElement = linkElementFactory.build({ imageUrl: `https://abc.de/file/unknown-file-id` }); - const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock, contextExternalToolService, toolFeatures); await visitor.visitLinkElementAsync(linkElement); const copy = visitor.copyMap.get(linkElement.id) as LinkElement; diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index a7a0d3b6d1b..b2a4e6a652d 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -1,5 +1,8 @@ import { FileRecordParentType } from '@infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { IToolFeatures } from '@modules/tool/tool-config'; import { AnyBoardDo, BoardCompositeVisitorAsync, @@ -23,7 +26,11 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { copyMap = new Map(); - constructor(private readonly fileCopyService: SchoolSpecificFileCopyService) {} + constructor( + private readonly fileCopyService: SchoolSpecificFileCopyService, + private readonly contextExternalToolService: ContextExternalToolService, + private readonly toolFeatures: IToolFeatures + ) {} async copy(original: AnyBoardDo): Promise { await original.acceptAsync(this); @@ -235,7 +242,9 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { return Promise.resolve(); } - visitExternalToolElementAsync(original: ExternalToolElement): Promise { + async visitExternalToolElementAsync(original: ExternalToolElement): Promise { + let status: CopyStatusEnum = CopyStatusEnum.SUCCESS; + const copy = new ExternalToolElement({ id: new ObjectId().toHexString(), contextExternalToolId: undefined, @@ -243,10 +252,28 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { createdAt: new Date(), updatedAt: new Date(), }); + + if (this.toolFeatures.ctlToolsCopyEnabled && original.contextExternalToolId) { + const tool: ContextExternalTool | null = await this.contextExternalToolService.findById( + original.contextExternalToolId + ); + + if (tool) { + const copiedTool: ContextExternalTool = await this.contextExternalToolService.copyContextExternalTool( + tool, + copy.id + ); + + copy.contextExternalToolId = copiedTool.id; + } else { + status = CopyStatusEnum.FAIL; + } + } + this.resultMap.set(original.id, { copyEntity: copy, type: CopyElementType.EXTERNAL_TOOL_ELEMENT, - status: CopyStatusEnum.SUCCESS, + status, }); this.copyMap.set(original.id, copy); 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 0dff9249331..e1c4269bbc9 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -12,40 +12,41 @@ export type CopyStatus = { }; export enum CopyElementType { - 'BOARD' = 'BOARD', - 'CARD' = 'CARD', - 'COLUMN' = 'COLUMN', - 'COLUMNBOARD' = 'COLUMNBOARD', - 'CONTENT' = 'CONTENT', - 'COURSE' = 'COURSE', - 'COURSEGROUP_GROUP' = 'COURSEGROUP_GROUP', - 'EXTERNAL_TOOL_ELEMENT' = 'EXTERNAL_TOOL_ELEMENT', - 'FILE' = 'FILE', - 'FILE_ELEMENT' = 'FILE_ELEMENT', - 'DRAWING_ELEMENT' = 'DRAWING_ELEMENT', - 'FILE_GROUP' = 'FILE_GROUP', - 'LEAF' = 'LEAF', - 'LESSON' = 'LESSON', - 'LESSON_CONTENT_ETHERPAD' = 'LESSON_CONTENT_ETHERPAD', - 'LESSON_CONTENT_GEOGEBRA' = 'LESSON_CONTENT_GEOGEBRA', - 'LESSON_CONTENT_GROUP' = 'LESSON_CONTENT_GROUP', - 'LESSON_CONTENT_LERNSTORE' = 'LESSON_CONTENT_LERNSTORE', - 'LESSON_CONTENT_NEXBOARD' = 'LESSON_CONTENT_NEXBOARD', - 'LESSON_CONTENT_TASK' = 'LESSON_CONTENT_TASK', - 'LESSON_CONTENT_TEXT' = 'LESSON_CONTENT_TEXT', - 'LERNSTORE_MATERIAL' = 'LERNSTORE_MATERIAL', - 'LERNSTORE_MATERIAL_GROUP' = 'LERNSTORE_MATERIAL_GROUP', - 'LINK_ELEMENT' = 'LINK_ELEMENT', - 'LTITOOL_GROUP' = 'LTITOOL_GROUP', - 'METADATA' = 'METADATA', - 'RICHTEXT_ELEMENT' = 'RICHTEXT_ELEMENT', - 'SUBMISSION_CONTAINER_ELEMENT' = 'SUBMISSION_CONTAINER_ELEMENT', - 'SUBMISSION_ITEM' = 'SUBMISSION_ITEM', - 'SUBMISSION_GROUP' = 'SUBMISSION_GROUP', - 'TASK' = 'TASK', - 'TASK_GROUP' = 'TASK_GROUP', - 'TIME_GROUP' = 'TIME_GROUP', - 'USER_GROUP' = 'USER_GROUP', + BOARD = 'BOARD', + CARD = 'CARD', + COLUMN = 'COLUMN', + COLUMNBOARD = 'COLUMNBOARD', + CONTENT = 'CONTENT', + COURSE = 'COURSE', + COURSEGROUP_GROUP = 'COURSEGROUP_GROUP', + EXTERNAL_TOOL = 'EXTERNAL_TOOL', + EXTERNAL_TOOL_ELEMENT = 'EXTERNAL_TOOL_ELEMENT', + FILE = 'FILE', + FILE_ELEMENT = 'FILE_ELEMENT', + DRAWING_ELEMENT = 'DRAWING_ELEMENT', + FILE_GROUP = 'FILE_GROUP', + LEAF = 'LEAF', + LESSON = 'LESSON', + LESSON_CONTENT_ETHERPAD = 'LESSON_CONTENT_ETHERPAD', + LESSON_CONTENT_GEOGEBRA = 'LESSON_CONTENT_GEOGEBRA', + LESSON_CONTENT_GROUP = 'LESSON_CONTENT_GROUP', + LESSON_CONTENT_LERNSTORE = 'LESSON_CONTENT_LERNSTORE', + LESSON_CONTENT_NEXBOARD = 'LESSON_CONTENT_NEXBOARD', + LESSON_CONTENT_TASK = 'LESSON_CONTENT_TASK', + LESSON_CONTENT_TEXT = 'LESSON_CONTENT_TEXT', + LERNSTORE_MATERIAL = 'LERNSTORE_MATERIAL', + LERNSTORE_MATERIAL_GROUP = 'LERNSTORE_MATERIAL_GROUP', + LINK_ELEMENT = 'LINK_ELEMENT', + LTITOOL_GROUP = 'LTITOOL_GROUP', + METADATA = 'METADATA', + RICHTEXT_ELEMENT = 'RICHTEXT_ELEMENT', + SUBMISSION_CONTAINER_ELEMENT = 'SUBMISSION_CONTAINER_ELEMENT', + SUBMISSION_ITEM = 'SUBMISSION_ITEM', + SUBMISSION_GROUP = 'SUBMISSION_GROUP', + TASK = 'TASK', + TASK_GROUP = 'TASK_GROUP', + TIME_GROUP = 'TIME_GROUP', + USER_GROUP = 'USER_GROUP', } export enum CopyStatusEnum { diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index f78d74dfafa..d6516fb7f84 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -13,6 +13,7 @@ import { UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; import { BoardCopyService, ColumnBoardTargetService, @@ -23,9 +24,18 @@ import { DashboardService, RoomsService, } from './service'; +import { ToolConfigModule } from '../tool/tool-config.module'; @Module({ - imports: [LessonModule, TaskModule, CopyHelperModule, BoardModule, LoggerModule], + imports: [ + LessonModule, + TaskModule, + CopyHelperModule, + BoardModule, + LoggerModule, + ContextExternalToolModule, + ToolConfigModule, + ], providers: [ { provide: 'DASHBOARD_REPO', diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts index c6982645c93..5a5dab7c5ac 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts @@ -1,17 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; import { LessonCopyService } from '@modules/lesson/service'; +import { ToolContextType } from '@modules/tool/common/enum'; +import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Test, TestingModule } from '@nestjs/testing'; import { Course } from '@shared/domain/entity'; import { BoardRepo, CourseRepo, UserRepo } from '@shared/repo'; import { boardFactory, + contextExternalToolFactory, courseFactory, courseGroupFactory, schoolFactory, setupEntities, userFactory, } from '@shared/testing'; +import { IToolFeatures } from '@src/modules/tool/tool-config'; +import { ToolFeatures } from '@modules/tool/tool-config'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; import { RoomsService } from './rooms.service'; @@ -26,6 +32,8 @@ describe('course copy service', () => { let lessonCopyService: DeepMocked; let copyHelperService: DeepMocked; let userRepo: DeepMocked; + let contextExternalToolService: DeepMocked; + let toolFeatures: IToolFeatures; afterAll(async () => { await module.close(); @@ -68,6 +76,16 @@ describe('course copy service', () => { provide: UserRepo, useValue: createMock(), }, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + ctlToolsTabEnabled: false, + }, + }, ], }).compile(); @@ -79,6 +97,8 @@ describe('course copy service', () => { lessonCopyService = module.get(LessonCopyService); copyHelperService = module.get(CopyHelperService); userRepo = module.get(UserRepo); + contextExternalToolService = module.get(ContextExternalToolService); + toolFeatures = module.get(ToolFeatures); }); beforeEach(() => { @@ -93,12 +113,14 @@ describe('course copy service', () => { const originalBoard = boardFactory.build({ course }); const courseCopy = courseFactory.buildWithId({ teachers: [user] }); const boardCopy = boardFactory.build({ course: courseCopy }); + const tools: ContextExternalTool[] = contextExternalToolFactory.buildList(2); userRepo.findById.mockResolvedValue(user); courseRepo.findById.mockResolvedValue(course); courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); boardRepo.findByCourseId.mockResolvedValue(originalBoard); roomsService.updateBoard.mockResolvedValue(originalBoard); + contextExternalToolService.findAllByContext.mockResolvedValue(tools); const courseCopyName = 'Copy'; copyHelperService.deriveCopyName.mockReturnValue(courseCopyName); @@ -115,6 +137,8 @@ describe('course copy service', () => { lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(boardCopyStatus); + toolFeatures.ctlToolsCopyEnabled = true; + return { user, course, @@ -124,6 +148,7 @@ describe('course copy service', () => { courseCopyName, allCourses, boardCopyStatus, + tools, }; }; @@ -310,6 +335,76 @@ describe('course copy service', () => { expect(courseCopy.color).toEqual(course.color); }); + + it('should find all ctl tools for this course', async () => { + const { course, user } = setup(); + await service.copyCourse({ userId: user.id, courseId: course.id }); + + expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith({ + id: course.id, + type: ToolContextType.COURSE, + }); + }); + + it('should copy all ctl tools', async () => { + const { course, user, tools } = setup(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseCopy = status.copyEntity as Course; + + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[0], courseCopy.id); + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[1], courseCopy.id); + }); + }); + + describe('when FEATURE_CTL_TOOLS_COPY_ENABLED is false', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const allCourses = courseFactory.buildList(3, { teachers: [user] }); + const course = allCourses[0]; + const originalBoard = boardFactory.build({ course }); + + userRepo.findById.mockResolvedValue(user); + courseRepo.findById.mockResolvedValue(course); + courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); + boardRepo.findByCourseId.mockResolvedValue(originalBoard); + roomsService.updateBoard.mockResolvedValue(originalBoard); + + const courseCopyName = 'Copy'; + copyHelperService.deriveCopyName.mockReturnValue(courseCopyName); + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.SUCCESS); + + const boardCopyStatus = { + title: 'boardCopy', + type: CopyElementType.BOARD, + status: CopyStatusEnum.SUCCESS, + copyEntity: boardFactory.build(), + elements: [], + }; + boardCopyService.copyBoard.mockResolvedValue(boardCopyStatus); + + lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(boardCopyStatus); + + toolFeatures.ctlToolsCopyEnabled = false; + + return { + user, + course, + }; + }; + + it('should not find ctl tools', async () => { + const { course, user } = setup(); + await service.copyCourse({ userId: user.id, courseId: course.id }); + + expect(contextExternalToolService.findAllByContext).not.toHaveBeenCalled(); + }); + + it('should not copy ctl tools', async () => { + const { course, user } = setup(); + await service.copyCourse({ userId: user.id, courseId: course.id }); + + expect(contextExternalToolService.copyContextExternalTool).not.toHaveBeenCalled(); + }); }); describe('when course is empty', () => { diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index c4857f49416..15b67e8fbd6 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -1,8 +1,12 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { Injectable } from '@nestjs/common'; +import { ToolContextType } from '@modules/tool/common/enum'; +import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { Inject, Injectable } from '@nestjs/common'; import { Course, User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { BoardRepo, CourseRepo, UserRepo } from '@shared/repo'; +import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { BoardCopyService } from './board-copy.service'; import { RoomsService } from './rooms.service'; @@ -15,12 +19,14 @@ type CourseCopyParams = { @Injectable() export class CourseCopyService { constructor( + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, private readonly courseRepo: CourseRepo, private readonly boardRepo: BoardRepo, private readonly roomsService: RoomsService, private readonly boardCopyService: BoardCopyService, private readonly copyHelperService: CopyHelperService, - private readonly userRepo: UserRepo + private readonly userRepo: UserRepo, + private readonly contextExternalToolService: ContextExternalToolService ) {} async copyCourse({ @@ -46,6 +52,23 @@ export class CourseCopyService { // copy course and board const courseCopy = await this.copyCourseEntity({ user, originalCourse, copyName }); + if (this.toolFeatures.ctlToolsCopyEnabled) { + const contextRef: ContextRef = { id: courseId, type: ToolContextType.COURSE }; + const contextExternalToolsInContext: ContextExternalTool[] = + await this.contextExternalToolService.findAllByContext(contextRef); + + await Promise.all( + contextExternalToolsInContext.map(async (tool: ContextExternalTool): Promise => { + const copiedTool: ContextExternalTool = await this.contextExternalToolService.copyContextExternalTool( + tool, + courseCopy.id + ); + + return copiedTool; + }) + ); + } + const boardStatus = await this.boardCopyService.copyBoard({ originalBoard, destinationCourse: courseCopy, user }); const finishedCourseCopy = await this.finishCourseCopying(courseCopy); @@ -97,9 +120,19 @@ export class CourseCopyService { boardStatus, ]; + if (this.toolFeatures.ctlToolsCopyEnabled) { + elements.push({ + type: CopyElementType.EXTERNAL_TOOL, + status: CopyStatusEnum.SUCCESS, + }); + } + const courseGroupsExist = originalCourse.getCourseGroupItems().length > 0; if (courseGroupsExist) { - elements.push({ type: CopyElementType.COURSEGROUP_GROUP, status: CopyStatusEnum.NOT_IMPLEMENTED }); + elements.push({ + type: CopyElementType.COURSEGROUP_GROUP, + status: CopyStatusEnum.NOT_IMPLEMENTED, + }); } const status = { diff --git a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts index 66b0b2ecf4f..8a4fa696b23 100644 --- a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts +++ b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts @@ -15,6 +15,12 @@ export class ContextExternalToolConfigurationStatusResponse { }) isOutdatedOnScopeContext: boolean; + @ApiProperty({ + type: Boolean, + description: 'True, if a configured parameter on the context external tool is missing a value', + }) + isIncompleteOnScopeContext: boolean; + @ApiProperty({ type: Boolean, description: 'Is the tool deactivated, because of superhero or school administrator', @@ -24,6 +30,7 @@ export class ContextExternalToolConfigurationStatusResponse { constructor(props: ContextExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isIncompleteOnScopeContext = props.isIncompleteOnScopeContext; this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts index ac66651841a..72daab380e8 100644 --- a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts @@ -3,11 +3,14 @@ export class ContextExternalToolConfigurationStatus { isOutdatedOnScopeContext: boolean; + isIncompleteOnScopeContext: boolean; + isDeactivated: boolean; constructor(props: ContextExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isIncompleteOnScopeContext = props.isIncompleteOnScopeContext; this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/domain/error/index.ts b/apps/server/src/modules/tool/common/domain/error/index.ts new file mode 100644 index 00000000000..4f0b5731e45 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/index.ts @@ -0,0 +1,6 @@ +export { ToolParameterDuplicateLoggableException } from './tool-parameter-duplicate.loggable-exception'; +export { ToolParameterRequiredLoggableException } from './tool-parameter-required.loggable-exception'; +export { ToolParameterUnknownLoggableException } from './tool-parameter-unknown.loggable-exception'; +export { ToolParameterValueRegexLoggableException } from './tool-parameter-value-regex.loggable-exception'; +export { ToolParameterTypeMismatchLoggableException } from './tool-parameter-type-mismatch.loggable-exception'; +export { ToolParameterValueMissingLoggableException } from './tool-parameter-value-missing.loggable-exception'; diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.spec.ts new file mode 100644 index 00000000000..f954eb20f25 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.spec.ts @@ -0,0 +1,20 @@ +import { ToolParameterDuplicateLoggableException } from './tool-parameter-duplicate.loggable-exception'; + +describe(ToolParameterDuplicateLoggableException.name, () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const exception = new ToolParameterDuplicateLoggableException('parameter1'); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_DUPLICATE', + message: 'The parameter is defined multiple times.', + stack: exception.stack, + data: { + parameterName: 'parameter1', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.ts new file mode 100644 index 00000000000..861d4247d6d --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-duplicate.loggable-exception.ts @@ -0,0 +1,19 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ToolParameterDuplicateLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterName: string) { + super(`tool_param_duplicate: The parameter with name ${parameterName} is defined multiple times.`); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_DUPLICATE', + message: 'The parameter is defined multiple times.', + stack: this.stack, + data: { + parameterName: this.parameterName, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.spec.ts new file mode 100644 index 00000000000..a3f5aae51ad --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.spec.ts @@ -0,0 +1,33 @@ +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter } from '../custom-parameter.do'; +import { ToolParameterRequiredLoggableException } from './tool-parameter-required.loggable-exception'; + +describe(ToolParameterRequiredLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const parameter: CustomParameter = customParameterFactory.build(); + + const exception: ToolParameterRequiredLoggableException = new ToolParameterRequiredLoggableException(parameter); + + return { + parameter, + exception, + }; + }; + + it('should return log message', () => { + const { exception, parameter } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_REQUIRED', + message: 'The parameter is required, but not found in the tool.', + stack: exception.stack, + data: { + parameterName: parameter.name, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.ts new file mode 100644 index 00000000000..20448c1feb6 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-required.loggable-exception.ts @@ -0,0 +1,22 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { CustomParameter } from '../custom-parameter.do'; + +export class ToolParameterRequiredLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterDeclaration: CustomParameter) { + super( + `tool_param_required: The parameter with name ${parameterDeclaration.name} is required but not found in the tool.` + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_REQUIRED', + message: 'The parameter is required, but not found in the tool.', + stack: this.stack, + data: { + parameterName: this.parameterDeclaration.name, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.spec.ts new file mode 100644 index 00000000000..76384e44cf5 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.spec.ts @@ -0,0 +1,36 @@ +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter } from '../custom-parameter.do'; +import { ToolParameterTypeMismatchLoggableException } from './tool-parameter-type-mismatch.loggable-exception'; + +describe(ToolParameterTypeMismatchLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const parameter: CustomParameter = customParameterFactory.build(); + + const exception: ToolParameterTypeMismatchLoggableException = new ToolParameterTypeMismatchLoggableException( + parameter + ); + + return { + parameter, + exception, + }; + }; + + it('should return log message', () => { + const { exception, parameter } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_TYPE_MISMATCH', + message: 'The parameter value has the wrong type.', + stack: exception.stack, + data: { + parameterName: parameter.name, + parameterType: parameter.type, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.ts new file mode 100644 index 00000000000..34af9b0c86a --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-type-mismatch.loggable-exception.ts @@ -0,0 +1,23 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { CustomParameter } from '../custom-parameter.do'; + +export class ToolParameterTypeMismatchLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterDeclaration: CustomParameter) { + super( + `tool_param_type_mismatch: The value of parameter with name ${parameterDeclaration.name} should be of type ${parameterDeclaration.type}.` + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_TYPE_MISMATCH', + message: 'The parameter value has the wrong type.', + stack: this.stack, + data: { + parameterName: this.parameterDeclaration.name, + parameterType: this.parameterDeclaration.type, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.spec.ts new file mode 100644 index 00000000000..902107b3d3a --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { CustomParameterEntry } from '../custom-parameter-entry.do'; +import { ToolParameterUnknownLoggableException } from './tool-parameter-unknown.loggable-exception'; + +describe(ToolParameterUnknownLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const parameter: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: 'value1', + }); + + const exception: ToolParameterUnknownLoggableException = new ToolParameterUnknownLoggableException(parameter); + + return { + parameter, + exception, + }; + }; + + it('should return log message', () => { + const { exception, parameter } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_UNKNOWN', + message: 'The parameter is not part of this tool.', + stack: exception.stack, + data: { + parameterName: parameter.name, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.ts new file mode 100644 index 00000000000..4f269c5bfb2 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-unknown.loggable-exception.ts @@ -0,0 +1,20 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { CustomParameterEntry } from '../custom-parameter-entry.do'; + +export class ToolParameterUnknownLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterEntry: CustomParameterEntry) { + super(`tool_param_unknown: The parameter with name ${parameterEntry.name} is not part of this tool.`); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_UNKNOWN', + message: 'The parameter is not part of this tool.', + stack: this.stack, + data: { + parameterName: this.parameterEntry.name, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..0215bc714dd --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter } from '../custom-parameter.do'; +import { ToolParameterValueMissingLoggableException } from './tool-parameter-value-missing.loggable-exception'; + +describe(ToolParameterValueMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const parameter: CustomParameter = customParameterFactory.build(); + + const exception: ToolParameterValueMissingLoggableException = new ToolParameterValueMissingLoggableException( + parameter + ); + + return { + parameter, + exception, + }; + }; + + it('should return log message', () => { + const { exception, parameter } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_VALUE_MISSING', + message: 'The parameter has no value.', + stack: exception.stack, + data: { + parameterName: parameter.name, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.ts new file mode 100644 index 00000000000..e31f92f01d6 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-missing.loggable-exception.ts @@ -0,0 +1,20 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { CustomParameter } from '../custom-parameter.do'; + +export class ToolParameterValueMissingLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterDeclaration: CustomParameter) { + super(`tool_param_value_missing: The parameter with name ${parameterDeclaration.name} has no value`); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_VALUE_MISSING', + message: 'The parameter has no value.', + stack: this.stack, + data: { + parameterName: this.parameterDeclaration.name, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.spec.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.spec.ts new file mode 100644 index 00000000000..e1a04a1f00c --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter } from '../custom-parameter.do'; +import { ToolParameterValueRegexLoggableException } from './tool-parameter-value-regex.loggable-exception'; + +describe(ToolParameterValueRegexLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const parameter: CustomParameter = customParameterFactory.build(); + + const exception: ToolParameterValueRegexLoggableException = new ToolParameterValueRegexLoggableException( + parameter + ); + + return { + parameter, + exception, + }; + }; + + it('should return log message', () => { + const { exception, parameter } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_PARAMETER_VALUE_REGEX', + message: 'The parameter value does not fit the regex.', + stack: exception.stack, + data: { + parameterName: parameter.name, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.ts b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.ts new file mode 100644 index 00000000000..2650572c7c3 --- /dev/null +++ b/apps/server/src/modules/tool/common/domain/error/tool-parameter-value-regex.loggable-exception.ts @@ -0,0 +1,22 @@ +import { ValidationError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { CustomParameter } from '../custom-parameter.do'; + +export class ToolParameterValueRegexLoggableException extends ValidationError implements Loggable { + constructor(private readonly parameterDeclaration: CustomParameter) { + super( + `tool_param_value_regex: The given entry for the parameter with name ${parameterDeclaration.name} does not fit the regex.` + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'TOOL_PARAMETER_VALUE_REGEX', + message: 'The parameter value does not fit the regex.', + stack: this.stack, + data: { + parameterName: this.parameterDeclaration.name, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/common/domain/index.ts b/apps/server/src/modules/tool/common/domain/index.ts index 27dd13a3fb5..9669e9fefe2 100644 --- a/apps/server/src/modules/tool/common/domain/index.ts +++ b/apps/server/src/modules/tool/common/domain/index.ts @@ -1,3 +1,11 @@ +export { + ToolParameterDuplicateLoggableException, + ToolParameterRequiredLoggableException, + ToolParameterUnknownLoggableException, + ToolParameterValueRegexLoggableException, + ToolParameterTypeMismatchLoggableException, + ToolParameterValueMissingLoggableException, +} from './error'; export * from './custom-parameter.do'; export * from './custom-parameter-entry.do'; export * from './context-external-tool-configuration-status'; diff --git a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts index 3ba0b6d9328..665da76b84b 100644 --- a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts @@ -7,6 +7,7 @@ export class ToolStatusResponseMapper { new ContextExternalToolConfigurationStatusResponse({ isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, isOutdatedOnScopeContext: status.isOutdatedOnScopeContext, + isIncompleteOnScopeContext: status.isIncompleteOnScopeContext, isDeactivated: status.isDeactivated, }); diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts deleted file mode 100644 index 1f76ec01a14..00000000000 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts +++ /dev/null @@ -1,733 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { - contextExternalToolFactory, - customParameterFactory, - externalToolFactory, - schoolExternalToolFactory, -} from '@shared/testing'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ExternalTool } from '../../external-tool/domain'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { CustomParameter } from '../domain'; -import { CustomParameterScope, CustomParameterType } from '../enum'; -import { CommonToolValidationService } from './common-tool-validation.service'; - -describe('CommonToolValidationService', () => { - let service: CommonToolValidationService; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CommonToolValidationService], - }).compile(); - - service = module.get(CommonToolValidationService); - }); - - describe('isValueValidForType', () => { - describe('when parameter type is string', () => { - it('should return true', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.STRING, 'test'); - - expect(result).toEqual(true); - }); - }); - - describe('when parameter type is boolean', () => { - describe('when value is true', () => { - it('should return true', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'true'); - - expect(result).toEqual(true); - }); - }); - - describe('when value is false', () => { - it('should return true', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'false'); - - expect(result).toEqual(true); - }); - }); - - describe('when value is not true or false', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.BOOLEAN, 'other'); - - expect(result).toEqual(false); - }); - }); - }); - - describe('when parameter type is number', () => { - describe('when value is a number', () => { - it('should return true', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.NUMBER, '1234'); - - expect(result).toEqual(true); - }); - }); - - describe('when value is not a number', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.NUMBER, 'NaN'); - - expect(result).toEqual(false); - }); - }); - }); - - describe('when defining a value for parameter of type auto_contextId', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_CONTEXTID, 'test'); - - expect(result).toEqual(false); - }); - }); - - describe('when defining a value for parameter of type auto_contextId', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_CONTEXTNAME, 'test'); - - expect(result).toEqual(false); - }); - }); - - describe('when defining a value for parameter of type auto_contextId', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_SCHOOLID, 'test'); - - expect(result).toEqual(false); - }); - }); - - describe('when defining a value for parameter of type auto_contextId', () => { - it('should return false', () => { - const result: boolean = service.isValueValidForType(CustomParameterType.AUTO_SCHOOLNUMBER, 'test'); - - expect(result).toEqual(false); - }); - }); - }); - - describe('checkCustomParameterEntries', () => { - const createTools = ( - externalToolMock?: Partial, - schoolExternalToolMock?: Partial, - contextExternalToolMock?: Partial - ) => { - const externalTool: ExternalTool = new ExternalTool({ - ...externalToolFactory.buildWithId(), - ...externalToolMock, - }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - ...schoolExternalToolFactory.buildWithId(), - ...schoolExternalToolMock, - }); - const schoolExternalToolId = schoolExternalTool.id as string; - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ - ...contextExternalToolFactory.buildWithId(), - ...contextExternalToolMock, - }); - - return { - externalTool, - schoolExternalTool, - schoolExternalToolId, - contextExternalTool, - }; - }; - - describe('when a parameter is a duplicate', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - parameters: [ - customParameterFactory.build({ - name: 'duplicate', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - isOptional: true, - }), - ], - }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - toolId: externalTool.id, - parameters: [ - { name: 'duplicate', value: undefined }, - { name: 'duplicate', value: undefined }, - ], - }); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_duplicate'); - }); - }); - - describe('when a parameter is unknown', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId({ - parameters: [], - }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - toolId: externalTool.id, - parameters: [{ name: 'unknownParameter', value: undefined }], - }); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_unknown'); - }); - }); - - describe('when checking parameter is required', () => { - describe('and given parameter is not optional and parameter value is empty', () => { - const setup = () => { - const requiredParam: CustomParameter = customParameterFactory.build({ - name: 'requiredParam', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - isOptional: false, - }); - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [requiredParam], - }, - { - parameters: [{ name: 'requiredParam', value: '' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_required'); - }); - }); - }); - - describe('when checking parameters of school external tool', () => { - const setup = () => { - const requiredContextParam: CustomParameter = customParameterFactory.build({ - name: 'missingContextParam', - isOptional: false, - scope: CustomParameterScope.CONTEXT, - type: CustomParameterType.BOOLEAN, - }); - const schoolParam: CustomParameter = customParameterFactory.build({ - name: 'schoolParam', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.BOOLEAN, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [requiredContextParam, schoolParam] }, - { - parameters: [{ name: 'schoolParam', value: 'true' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should not fail because of missing required context param', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError(); - }); - }); - - describe('when parameter is not school or context', () => { - const setup = () => { - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [ - customParameterFactory.build({ - name: 'notSchoolParam', - scope: CustomParameterScope.GLOBAL, - type: CustomParameterType.BOOLEAN, - }), - customParameterFactory.build({ - name: 'schoolParam', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.BOOLEAN, - }), - ], - }, - { - parameters: [{ name: 'schoolParam', value: 'true' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without any error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError(); - }); - }); - - describe('when parameter scope is school', () => { - describe('when required parameter is missing', () => { - const setup = () => { - const missingParam: CustomParameter = customParameterFactory.build({ - name: 'isMissing', - isOptional: false, - scope: CustomParameterScope.SCHOOL, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [missingParam] }, - { - parameters: [], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_required'); - }); - }); - - describe('when parameter is optional and was not defined', () => { - const setup = () => { - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [ - customParameterFactory.build({ - name: 'optionalParameter', - scope: CustomParameterScope.SCHOOL, - isOptional: true, - }), - customParameterFactory.build({ - name: 'requiredParameter', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - isOptional: false, - }), - ], - }, - { - parameters: [{ name: 'requiredParameter', value: 'value' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_required'); - }); - }); - }); - - describe('when parameter scope is context', () => { - describe('when required parameter is missing', () => { - const setup = () => { - const missingParam: CustomParameter = customParameterFactory.build({ - name: 'isMissing', - isOptional: false, - scope: CustomParameterScope.CONTEXT, - }); - - const { externalTool, contextExternalTool } = createTools( - { - parameters: [missingParam], - }, - undefined, - { - parameters: [], - } - ); - - return { - externalTool, - contextExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, contextExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, contextExternalTool); - - expect(func).toThrowError('tool_param_required'); - }); - }); - - describe('when parameter is optional but is missing on params', () => { - const setup = () => { - const param: CustomParameter = customParameterFactory.build({ - name: 'notChecked', - scope: CustomParameterScope.CONTEXT, - isOptional: true, - }); - - const { externalTool, contextExternalTool } = createTools( - { - parameters: [param], - }, - undefined, - { - parameters: [{ name: 'anotherParam', value: 'value' }], - } - ); - - return { - externalTool, - contextExternalTool, - }; - }; - - it('should return without error ', () => { - const { externalTool, contextExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, contextExternalTool); - - expect(func).not.toThrowError('tool_param_required'); - }); - }); - }); - - describe('when checking parameter type string', () => { - const setup = () => { - const correctTypeParam: CustomParameter = customParameterFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'dasIstEinString' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when checking parameter type number', () => { - describe('when type matches param value', () => { - const setup = () => { - const correctTypeParam: CustomParameter = customParameterFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.NUMBER, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: '1234' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when type not matches param value', () => { - const setup = () => { - const wrongTypeParam: CustomParameter = customParameterFactory.build({ - name: 'wrongType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.NUMBER, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [wrongTypeParam] }, - { - parameters: [{ name: wrongTypeParam.name, value: '17271hsadas' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_type_mismatch'); - }); - }); - }); - - describe('when checking parameter type boolean', () => { - describe('when type matches param value', () => { - const setup = () => { - const correctTypeParam: CustomParameter = customParameterFactory.build({ - name: 'correctType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.BOOLEAN, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [correctTypeParam] }, - { - parameters: [{ name: correctTypeParam.name, value: 'true' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_type_mismatch'); - }); - }); - - describe('when type not matches param value', () => { - const setup = () => { - const wrongTypeParam: CustomParameter = customParameterFactory.build({ - name: 'wrongType', - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.BOOLEAN, - }); - - const { externalTool, schoolExternalTool } = createTools( - { parameters: [wrongTypeParam] }, - { - parameters: [{ name: wrongTypeParam.name, value: '17271hsadas' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_type_mismatch'); - }); - }); - }); - - describe('when validating regex', () => { - describe('when no regex is given', () => { - const setup = () => { - const undefinedRegex: CustomParameter = customParameterFactory.build({ - name: 'undefinedRegex', - isOptional: false, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: undefined, - }); - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [undefinedRegex], - }, - { - parameters: [{ name: 'undefinedRegex', value: 'xxxx' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_value_regex'); - }); - }); - - describe('when regex is given and param value is valid', () => { - const setup = () => { - const validRegex: CustomParameter = customParameterFactory.build({ - name: 'validRegex', - isOptional: false, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: '[x]', - }); - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [validRegex], - }, - { - parameters: [{ name: 'validRegex', value: 'xxxx' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_value_regex'); - }); - }); - - describe('when regex is given and param value is invalid', () => { - const setup = () => { - const validRegex: CustomParameter = customParameterFactory.build({ - name: 'validRegex', - isOptional: false, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: '[x]', - }); - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [validRegex], - }, - { - parameters: [{ name: 'validRegex', value: 'abcdefasdhasd' }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should throw error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).toThrowError('tool_param_value_regex'); - }); - }); - - describe('when parameter is optional and a regex is given, but the param value is undefined', () => { - const setup = () => { - const optionalRegex: CustomParameter = customParameterFactory.build({ - name: 'optionalRegex', - isOptional: true, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: '[x]', - }); - const { externalTool, schoolExternalTool } = createTools( - { - parameters: [optionalRegex], - }, - { - parameters: [{ name: 'optionalRegex', value: undefined }], - } - ); - - return { - externalTool, - schoolExternalTool, - }; - }; - - it('should return without error', () => { - const { externalTool, schoolExternalTool } = setup(); - - const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - - expect(func).not.toThrowError('tool_param_value_regex'); - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts deleted file mode 100644 index e6c3a31b288..00000000000 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ValidationError } from '@shared/common'; -import { isNaN } from 'lodash'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ExternalTool } from '../../external-tool/domain'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { CustomParameter, CustomParameterEntry } from '../domain'; -import { CustomParameterScope, CustomParameterType } from '../enum'; - -export type ValidatableTool = SchoolExternalTool | ContextExternalTool; - -@Injectable() -export class CommonToolValidationService { - private static typeCheckers: { [key in CustomParameterType]: (val: string) => boolean } = { - [CustomParameterType.STRING]: () => true, - [CustomParameterType.NUMBER]: (val: string | undefined) => !isNaN(Number(val)), - [CustomParameterType.BOOLEAN]: (val: string | undefined) => val === 'true' || val === 'false', - [CustomParameterType.AUTO_CONTEXTID]: () => false, - [CustomParameterType.AUTO_CONTEXTNAME]: () => false, - [CustomParameterType.AUTO_SCHOOLID]: () => false, - [CustomParameterType.AUTO_SCHOOLNUMBER]: () => false, - }; - - public isValueValidForType(type: CustomParameterType, val: string): boolean { - const rule = CommonToolValidationService.typeCheckers[type]; - - const isValid: boolean = rule(val); - - return isValid; - } - - public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { - this.checkForDuplicateParameters(validatableTool); - - const parametersForScope: CustomParameter[] = (loadedExternalTool.parameters ?? []).filter( - (param: CustomParameter) => - (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || - (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) - ); - - this.checkForUnknownParameters(validatableTool, parametersForScope); - - this.checkValidityOfParameters(validatableTool, parametersForScope); - } - - private checkForDuplicateParameters(validatableTool: ValidatableTool): void { - const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => name); - - const uniqueNames: Set = new Set(caseInsensitiveNames); - - if (uniqueNames.size !== validatableTool.parameters.length) { - throw new ValidationError( - `tool_param_duplicate: The tool ${validatableTool.id ?? ''} contains multiple of the same custom parameters.` - ); - } - } - - private checkForUnknownParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { - for (const entry of validatableTool.parameters) { - const foundParameter: CustomParameter | undefined = parametersForScope.find( - ({ name }: CustomParameter): boolean => name === entry.name - ); - - if (!foundParameter) { - throw new ValidationError( - `tool_param_unknown: The parameter with name ${entry.name} is not part of this tool.` - ); - } - } - } - - private checkValidityOfParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { - for (const param of parametersForScope) { - const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( - ({ name }: CustomParameterEntry): boolean => name === param.name - ); - - this.validateParameter(param, foundEntry); - } - } - - private validateParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { - this.checkOptionalParameter(param, foundEntry); - - if (foundEntry) { - this.checkParameterType(foundEntry, param); - this.checkParameterRegex(foundEntry, param); - } - } - - private checkOptionalParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { - if (!foundEntry?.value && !param.isOptional) { - throw new ValidationError( - `tool_param_required: The parameter with name ${param.name} is required but not found in the tool.` - ); - } - } - - private checkParameterType(foundEntry: CustomParameterEntry, param: CustomParameter): void { - if (foundEntry.value !== undefined && !this.isValueValidForType(param.type, foundEntry.value)) { - throw new ValidationError( - `tool_param_type_mismatch: The value of parameter with name ${foundEntry.name} should be of type ${param.type}.` - ); - } - } - - private checkParameterRegex(foundEntry: CustomParameterEntry, param: CustomParameter): void { - if (foundEntry.value !== undefined && param.regex && !new RegExp(param.regex).test(foundEntry.value ?? '')) { - throw new ValidationError( - `tool_param_value_regex: The given entry for the parameter with name ${foundEntry.name} does not fit the regex.` - ); - } - } -} diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index 9b5404f7ae7..b33982b3c3f 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { ContextExternalTool } from '../../context-external-tool/domain'; import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ToolContextType } from '../enum'; import { ContextExternalToolConfigurationStatus } from '../domain'; +import { ToolContextType } from '../enum'; import { ToolVersion } from '../interface'; // TODO N21-1337 remove class when tool versioning is removed @@ -20,6 +20,7 @@ export class CommonToolService { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isIncompleteOnScopeContext: false, isDeactivated: false, }); diff --git a/apps/server/src/modules/tool/common/service/index.ts b/apps/server/src/modules/tool/common/service/index.ts index 2b2b9acae68..b7f626f1e42 100644 --- a/apps/server/src/modules/tool/common/service/index.ts +++ b/apps/server/src/modules/tool/common/service/index.ts @@ -1,2 +1,2 @@ export * from './common-tool.service'; -export * from './common-tool-validation.service'; +export { CommonToolValidationService, ToolParameterTypeValidationUtil } from './validation'; diff --git a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.spec.ts b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.spec.ts new file mode 100644 index 00000000000..8fe0c12d4b7 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.spec.ts @@ -0,0 +1,159 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ValidationError } from '@shared/common'; +import { + contextExternalToolFactory, + customParameterFactory, + externalToolFactory, + schoolExternalToolFactory, +} from '@shared/testing'; +import { CustomParameterEntry } from '../../domain'; +import { CustomParameterScope } from '../../enum'; +import { CommonToolValidationService } from './common-tool-validation.service'; + +describe('CommonToolValidationService', () => { + let service: CommonToolValidationService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CommonToolValidationService], + }).compile(); + + service = module.get(CommonToolValidationService); + }); + + describe('validateParameters', () => { + describe('when validating a valid school external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + parameters: [ + customParameterFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + }), + ], + }); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + parameters: [ + new CustomParameterEntry({ + name: 'param1', + value: 'test', + }), + ], + }); + + return { + externalTool, + schoolExternalTool, + }; + }; + + it('should return an empty array', () => { + const { externalTool, schoolExternalTool } = setup(); + + const result: ValidationError[] = service.validateParameters(externalTool, schoolExternalTool); + + expect(result).toHaveLength(0); + }); + }); + + describe('when validating an invalid school external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + parameters: [ + customParameterFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + }), + ], + }); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + parameters: [ + new CustomParameterEntry({ + name: 'param1', + }), + ], + }); + + return { + externalTool, + schoolExternalTool, + }; + }; + + it('should return a validation error', () => { + const { externalTool, schoolExternalTool } = setup(); + + const result: ValidationError[] = service.validateParameters(externalTool, schoolExternalTool); + + expect(result).toHaveLength(1); + }); + }); + + describe('when validating a valid context external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + parameters: [ + customParameterFactory.build({ + name: 'param1', + scope: CustomParameterScope.CONTEXT, + }), + ], + }); + const contextExternalTool = contextExternalToolFactory.buildWithId({ + parameters: [ + new CustomParameterEntry({ + name: 'param1', + value: 'test', + }), + ], + }); + + return { + externalTool, + contextExternalTool, + }; + }; + + it('should return an empty array', () => { + const { externalTool, contextExternalTool } = setup(); + + const result: ValidationError[] = service.validateParameters(externalTool, contextExternalTool); + + expect(result).toHaveLength(0); + }); + }); + + describe('when validating an invalid context external tool', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + parameters: [ + customParameterFactory.build({ + name: 'param1', + scope: CustomParameterScope.CONTEXT, + }), + ], + }); + const contextExternalTool = contextExternalToolFactory.buildWithId({ + parameters: [ + new CustomParameterEntry({ + name: 'param1', + }), + ], + }); + + return { + externalTool, + contextExternalTool, + }; + }; + + it('should return a validation error', () => { + const { externalTool, contextExternalTool } = setup(); + + const result: ValidationError[] = service.validateParameters(externalTool, contextExternalTool); + + expect(result).toHaveLength(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts new file mode 100644 index 00000000000..1ba0740d0dd --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { ValidationError } from '@shared/common'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ExternalTool } from '../../../external-tool/domain'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { CustomParameter } from '../../domain'; +import { CustomParameterScope } from '../../enum'; +import { + ParameterArrayDuplicateKeyValidator, + ParameterArrayEntryValidator, + ParameterArrayUnknownKeyValidator, + ParameterArrayValidator, +} from './rules'; + +export type ValidatableTool = SchoolExternalTool | ContextExternalTool; + +@Injectable() +export class CommonToolValidationService { + private readonly arrayValidators: ParameterArrayValidator[] = [ + new ParameterArrayDuplicateKeyValidator(), + new ParameterArrayUnknownKeyValidator(), + new ParameterArrayEntryValidator(), + ]; + + public validateParameters(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): ValidationError[] { + const errors: ValidationError[] = []; + + const parametersForScope: CustomParameter[] = (loadedExternalTool.parameters ?? []).filter( + (param: CustomParameter) => + (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || + (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) + ); + + this.arrayValidators.forEach((validator: ParameterArrayValidator) => { + const entryErrors: ValidationError[] = validator.validate(validatableTool.parameters, parametersForScope); + + errors.push(...entryErrors); + }); + + return errors; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/index.ts b/apps/server/src/modules/tool/common/service/validation/index.ts new file mode 100644 index 00000000000..0d065368f38 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/index.ts @@ -0,0 +1,2 @@ +export { CommonToolValidationService } from './common-tool-validation.service'; +export { ToolParameterTypeValidationUtil } from './tool-parameter-type-validation.util'; diff --git a/apps/server/src/modules/tool/common/service/validation/rules/index.ts b/apps/server/src/modules/tool/common/service/validation/rules/index.ts new file mode 100644 index 00000000000..e03736a6496 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/index.ts @@ -0,0 +1,8 @@ +export { ParameterArrayValidator } from './parameter-array-validator'; +export { ParameterEntryValidator } from './parameter-entry-validator'; +export { ParameterEntryTypeValidator } from './parameter-entry-type-validator'; +export { ParameterArrayEntryValidator } from './parameter-array-entry-validator'; +export { ParameterEntryRegexValidator } from './parameter-entry-regex-validator'; +export { ParameterEntryValueValidator } from './parameter-entry-value-validator'; +export { ParameterArrayUnknownKeyValidator } from './parameter-array-unknown-key-validator'; +export { ParameterArrayDuplicateKeyValidator } from './parameter-array-duplicate-key-validator'; diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.spec.ts new file mode 100644 index 00000000000..cad2091ad4e --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.spec.ts @@ -0,0 +1,57 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameterEntry, ToolParameterDuplicateLoggableException } from '../../../domain'; +import { ParameterArrayDuplicateKeyValidator } from './parameter-array-duplicate-key-validator'; + +describe(ParameterArrayDuplicateKeyValidator.name, () => { + describe('validate', () => { + describe('when there are no duplicate parameters', () => { + const setup = () => { + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'unique1', + }), + new CustomParameterEntry({ + name: 'unique2', + }), + ]; + + return { + entries, + }; + }; + + it('should return an empty array', () => { + const { entries } = setup(); + + const result: ValidationError[] = new ParameterArrayDuplicateKeyValidator().validate(entries, []); + + expect(result).toHaveLength(0); + }); + }); + + describe('when there are duplicate parameters', () => { + const setup = () => { + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'duplicate', + }), + new CustomParameterEntry({ + name: 'duplicate', + }), + ]; + + return { + entries, + }; + }; + + it('should return a validation error', () => { + const { entries } = setup(); + + const result: ValidationError[] = new ParameterArrayDuplicateKeyValidator().validate(entries, []); + + expect(result[0]).toBeInstanceOf(ToolParameterDuplicateLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.ts new file mode 100644 index 00000000000..f2b0ccbd075 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-duplicate-key-validator.ts @@ -0,0 +1,22 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterDuplicateLoggableException } from '../../../domain'; +import { ParameterArrayValidator } from './parameter-array-validator'; + +export class ParameterArrayDuplicateKeyValidator implements ParameterArrayValidator { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validate(entries: CustomParameterEntry[], declarations: CustomParameter[]): ValidationError[] { + const caseInsensitiveNames: string[] = entries.map(({ name }: CustomParameterEntry) => name.toLowerCase()); + + const duplicates: string[] = caseInsensitiveNames.filter( + (item, index) => caseInsensitiveNames.indexOf(item) !== index + ); + + const uniqueDuplicates: Set = new Set(duplicates); + + const errors: ValidationError[] = Array.from(uniqueDuplicates).map( + (parameterName: string) => new ToolParameterDuplicateLoggableException(parameterName) + ); + + return errors; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.spec.ts new file mode 100644 index 00000000000..f09acbc2e90 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.spec.ts @@ -0,0 +1,90 @@ +import { ValidationError } from '@shared/common'; +import { customParameterFactory } from '@shared/testing'; +import { + CustomParameter, + CustomParameterEntry, + ToolParameterRequiredLoggableException, + ToolParameterValueMissingLoggableException, +} from '../../../domain'; +import { ParameterArrayEntryValidator } from './parameter-array-entry-validator'; + +describe(ParameterArrayEntryValidator.name, () => { + describe('validate', () => { + describe('when all parameters are valid', () => { + const setup = () => { + const declarations: CustomParameter[] = customParameterFactory.buildList(1, { + name: 'param1', + }); + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'param1', + value: 'test', + }), + ]; + + return { + entries, + declarations, + }; + }; + + it('should return an empty array', () => { + const { entries, declarations } = setup(); + + const result: ValidationError[] = new ParameterArrayEntryValidator().validate(entries, declarations); + + expect(result).toHaveLength(0); + }); + }); + + describe('when a required parameter is not defined', () => { + const setup = () => { + const declarations: CustomParameter[] = customParameterFactory.buildList(1, { + name: 'param1', + isOptional: false, + }); + const entries: CustomParameterEntry[] = []; + + return { + declarations, + entries, + }; + }; + + it('should return a validation error', () => { + const { entries, declarations } = setup(); + + const result: ValidationError[] = new ParameterArrayEntryValidator().validate(entries, declarations); + + expect(result[0]).toBeInstanceOf(ToolParameterRequiredLoggableException); + }); + }); + + describe('when a required parameter fails the validations', () => { + const setup = () => { + const declarations: CustomParameter[] = customParameterFactory.buildList(1, { + name: 'param1', + isOptional: false, + }); + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'param1', + }), + ]; + + return { + declarations, + entries, + }; + }; + + it('should return a validation error', () => { + const { entries, declarations } = setup(); + + const result: ValidationError[] = new ParameterArrayEntryValidator().validate(entries, declarations); + + expect(result[0]).toBeInstanceOf(ToolParameterValueMissingLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.ts new file mode 100644 index 00000000000..66c7e34d553 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-entry-validator.ts @@ -0,0 +1,37 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterRequiredLoggableException } from '../../../domain'; +import { ParameterArrayValidator } from './parameter-array-validator'; +import { ParameterEntryRegexValidator } from './parameter-entry-regex-validator'; +import { ParameterEntryTypeValidator } from './parameter-entry-type-validator'; +import { ParameterEntryValidator } from './parameter-entry-validator'; +import { ParameterEntryValueValidator } from './parameter-entry-value-validator'; + +export class ParameterArrayEntryValidator implements ParameterArrayValidator { + private readonly entryValidators: ParameterEntryValidator[] = [ + new ParameterEntryValueValidator(), + new ParameterEntryTypeValidator(), + new ParameterEntryRegexValidator(), + ]; + + validate(entries: CustomParameterEntry[], declarations: CustomParameter[]): ValidationError[] { + const errors: ValidationError[] = []; + + for (const param of declarations) { + const foundEntry: CustomParameterEntry | undefined = entries.find( + ({ name }: CustomParameterEntry): boolean => name === param.name + ); + + if (foundEntry) { + this.entryValidators.forEach((validator: ParameterEntryValidator) => { + const entryErrors: ValidationError[] = validator.validate(foundEntry, param); + + errors.push(...entryErrors); + }); + } else if (!param.isOptional) { + errors.push(new ToolParameterRequiredLoggableException(param)); + } + } + + return errors; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.spec.ts new file mode 100644 index 00000000000..c73e6430406 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.spec.ts @@ -0,0 +1,60 @@ +import { ValidationError } from '@shared/common'; +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter, CustomParameterEntry, ToolParameterUnknownLoggableException } from '../../../domain'; +import { ParameterArrayUnknownKeyValidator } from './parameter-array-unknown-key-validator'; + +describe(ParameterArrayUnknownKeyValidator.name, () => { + describe('validate', () => { + describe('when there are no unknown parameters', () => { + const setup = () => { + const declarations: CustomParameter[] = customParameterFactory.buildList(1, { + name: 'param1', + }); + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'param1', + }), + ]; + + return { + entries, + declarations, + }; + }; + + it('should return an empty array', () => { + const { entries, declarations } = setup(); + + const result: ValidationError[] = new ParameterArrayUnknownKeyValidator().validate(entries, declarations); + + expect(result).toHaveLength(0); + }); + }); + + describe('when there are unknown parameters', () => { + const setup = () => { + const declarations: CustomParameter[] = customParameterFactory.buildList(1, { + name: 'param1', + }); + const entries: CustomParameterEntry[] = [ + new CustomParameterEntry({ + name: 'unknownParameter', + }), + ]; + + return { + declarations, + entries, + }; + }; + + it('should return a validation error', () => { + const { entries, declarations } = setup(); + + const result: ValidationError[] = new ParameterArrayUnknownKeyValidator().validate(entries, declarations); + + expect(result[0]).toBeInstanceOf(ToolParameterUnknownLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.ts new file mode 100644 index 00000000000..ef8e5d1e922 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-unknown-key-validator.ts @@ -0,0 +1,21 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterUnknownLoggableException } from '../../../domain'; +import { ParameterArrayValidator } from './parameter-array-validator'; + +export class ParameterArrayUnknownKeyValidator implements ParameterArrayValidator { + validate(entries: CustomParameterEntry[], declarations: CustomParameter[]): ValidationError[] { + const errors: ValidationError[] = []; + + for (const entry of entries) { + const foundParameter: CustomParameter | undefined = declarations.find( + ({ name }: CustomParameter): boolean => name === entry.name + ); + + if (!foundParameter) { + errors.push(new ToolParameterUnknownLoggableException(entry)); + } + } + + return errors; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-validator.ts new file mode 100644 index 00000000000..0360108332a --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-array-validator.ts @@ -0,0 +1,6 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry } from '../../../domain'; + +export interface ParameterArrayValidator { + validate(entries: CustomParameterEntry[], declarations: CustomParameter[]): ValidationError[]; +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.spec.ts new file mode 100644 index 00000000000..9dc245fe8d6 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.spec.ts @@ -0,0 +1,60 @@ +import { ValidationError } from '@shared/common'; +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter, CustomParameterEntry, ToolParameterValueRegexLoggableException } from '../../../domain'; +import { ParameterEntryRegexValidator } from './parameter-entry-regex-validator'; + +describe(ParameterEntryRegexValidator.name, () => { + describe('validate', () => { + describe('when the parameter fulfills the regex', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + regex: '^123$', + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: '123', + }); + + return { + entry, + declaration, + }; + }; + + it('should return an empty array', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryRegexValidator().validate(entry, declaration); + + expect(result).toHaveLength(0); + }); + }); + + describe('when the parameter does not fulfills the regex', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + regex: '^123$', + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: '456', + }); + + return { + entry, + declaration, + }; + }; + + it('should return a validation error', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryRegexValidator().validate(entry, declaration); + + expect(result[0]).toBeInstanceOf(ToolParameterValueRegexLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.ts new file mode 100644 index 00000000000..9adfc87cbe6 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-regex-validator.ts @@ -0,0 +1,13 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterValueRegexLoggableException } from '../../../domain'; +import { ParameterEntryValidator } from './parameter-entry-validator'; + +export class ParameterEntryRegexValidator implements ParameterEntryValidator { + public validate(entry: CustomParameterEntry, declaration: CustomParameter): ValidationError[] { + if (entry.value !== undefined && declaration.regex && !new RegExp(declaration.regex).test(entry.value)) { + return [new ToolParameterValueRegexLoggableException(declaration)]; + } + + return []; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.spec.ts new file mode 100644 index 00000000000..485d89a5354 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.spec.ts @@ -0,0 +1,61 @@ +import { ValidationError } from '@shared/common'; +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter, CustomParameterEntry, ToolParameterTypeMismatchLoggableException } from '../../../domain'; +import { CustomParameterType } from '../../../enum'; +import { ParameterEntryTypeValidator } from './parameter-entry-type-validator'; + +describe(ParameterEntryTypeValidator.name, () => { + describe('validate', () => { + describe('when the parameter has the correct type', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + type: CustomParameterType.NUMBER, + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: '123', + }); + + return { + entry, + declaration, + }; + }; + + it('should return an empty array', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryTypeValidator().validate(entry, declaration); + + expect(result).toHaveLength(0); + }); + }); + + describe('when the parameter does not have the correct type', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + type: CustomParameterType.NUMBER, + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: 'NaN', + }); + + return { + entry, + declaration, + }; + }; + + it('should return a validation error', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryTypeValidator().validate(entry, declaration); + + expect(result[0]).toBeInstanceOf(ToolParameterTypeMismatchLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.ts new file mode 100644 index 00000000000..42164a58e67 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-type-validator.ts @@ -0,0 +1,17 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterTypeMismatchLoggableException } from '../../../domain'; +import { ToolParameterTypeValidationUtil } from '../tool-parameter-type-validation.util'; +import { ParameterEntryValidator } from './parameter-entry-validator'; + +export class ParameterEntryTypeValidator implements ParameterEntryValidator { + public validate(entry: CustomParameterEntry, declaration: CustomParameter): ValidationError[] { + if ( + entry.value !== undefined && + !ToolParameterTypeValidationUtil.isValueValidForType(declaration.type, entry.value) + ) { + return [new ToolParameterTypeMismatchLoggableException(declaration)]; + } + + return []; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-validator.ts new file mode 100644 index 00000000000..af3c7501dee --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-validator.ts @@ -0,0 +1,6 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry } from '../../../domain'; + +export interface ParameterEntryValidator { + validate(entry: CustomParameterEntry, declaration: CustomParameter): ValidationError[]; +} diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.spec.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.spec.ts new file mode 100644 index 00000000000..449f688e93b --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.spec.ts @@ -0,0 +1,82 @@ +import { ValidationError } from '@shared/common'; +import { customParameterFactory } from '@shared/testing'; +import { CustomParameter, CustomParameterEntry, ToolParameterValueMissingLoggableException } from '../../../domain'; +import { ParameterEntryValueValidator } from './parameter-entry-value-validator'; + +describe(ParameterEntryValueValidator.name, () => { + describe('validate', () => { + describe('when the parameter has a value', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: '123', + }); + + return { + entry, + declaration, + }; + }; + + it('should return an empty array', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryValueValidator().validate(entry, declaration); + + expect(result).toHaveLength(0); + }); + }); + + describe('when the parameter value is an empty string', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + value: '', + }); + + return { + entry, + declaration, + }; + }; + + it('should return a validation error', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryValueValidator().validate(entry, declaration); + + expect(result[0]).toBeInstanceOf(ToolParameterValueMissingLoggableException); + }); + }); + + describe('when the parameter value is undefined', () => { + const setup = () => { + const declaration: CustomParameter = customParameterFactory.build({ + name: 'param1', + }); + const entry: CustomParameterEntry = new CustomParameterEntry({ + name: 'param1', + }); + + return { + entry, + declaration, + }; + }; + + it('should return a validation error', () => { + const { entry, declaration } = setup(); + + const result: ValidationError[] = new ParameterEntryValueValidator().validate(entry, declaration); + + expect(result[0]).toBeInstanceOf(ToolParameterValueMissingLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.ts b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.ts new file mode 100644 index 00000000000..952698ff104 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/rules/parameter-entry-value-validator.ts @@ -0,0 +1,13 @@ +import { ValidationError } from '@shared/common'; +import { CustomParameter, CustomParameterEntry, ToolParameterValueMissingLoggableException } from '../../../domain'; +import { ParameterEntryValidator } from './parameter-entry-validator'; + +export class ParameterEntryValueValidator implements ParameterEntryValidator { + public validate(entry: CustomParameterEntry, declaration: CustomParameter): ValidationError[] { + if (entry.value === undefined || entry.value === '') { + return [new ToolParameterValueMissingLoggableException(declaration)]; + } + + return []; + } +} 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 new file mode 100644 index 00000000000..4949a840f11 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts @@ -0,0 +1,99 @@ +import { CustomParameterType } from '../../enum'; +import { ToolParameterTypeValidationUtil } from './tool-parameter-type-validation.util'; + +describe(ToolParameterTypeValidationUtil.name, () => { + describe('isValueValidForType', () => { + describe('when the type is "string"', () => { + it('should return true', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.STRING, + '12345' + ); + + expect(result).toEqual(true); + }); + }); + + describe('when the type is "number" and the value is a number', () => { + it('should return true', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType(CustomParameterType.NUMBER, '17'); + + expect(result).toEqual(true); + }); + }); + + describe('when the type is "number" and the value is a not a number', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType(CustomParameterType.NUMBER, 'NaN'); + + expect(result).toEqual(false); + }); + }); + + describe('when the type is "boolean" and the value is a boolean', () => { + it('should return true', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.BOOLEAN, + 'true' + ); + + expect(result).toEqual(true); + }); + }); + + describe('when the type is "boolean" and the value is not a boolean', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.BOOLEAN, + 'not true' + ); + + expect(result).toEqual(false); + }); + }); + + describe('when the type is AUTO_CONTEXTNAME', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_CONTEXTNAME, + 'any value' + ); + + expect(result).toEqual(false); + }); + }); + + describe('when the type is AUTO_CONTEXTID', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_CONTEXTID, + 'any value' + ); + + expect(result).toEqual(false); + }); + }); + + describe('when the type is AUTO_SCHOOLID', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_SCHOOLID, + 'any value' + ); + + expect(result).toEqual(false); + }); + }); + + describe('when the type is AUTO_SCHOOLNUMBER', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_SCHOOLNUMBER, + '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 new file mode 100644 index 00000000000..f765cb68782 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts @@ -0,0 +1,22 @@ +import { isNaN } from 'lodash'; +import { CustomParameterType } from '../../enum'; + +export class ToolParameterTypeValidationUtil { + private static typeCheckers: { [key in CustomParameterType]: (val: string) => boolean } = { + [CustomParameterType.STRING]: () => true, + [CustomParameterType.NUMBER]: (val: string) => !isNaN(Number(val)), + [CustomParameterType.BOOLEAN]: (val: string) => val === 'true' || val === 'false', + [CustomParameterType.AUTO_CONTEXTID]: () => false, + [CustomParameterType.AUTO_CONTEXTNAME]: () => false, + [CustomParameterType.AUTO_SCHOOLID]: () => false, + [CustomParameterType.AUTO_SCHOOLNUMBER]: () => false, + }; + + public static isValueValidForType(type: CustomParameterType, val: string): boolean { + const rule = this.typeCheckers[type]; + + const isValid: boolean = rule(val); + + return isValid; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index 6a645cc944d..2561db5489a 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -98,7 +98,7 @@ describe('ToolContextController (API)', () => { contextType: ToolContextType.COURSE, parameters: [ { name: 'param1', value: 'value' }, - { name: 'param2', value: '' }, + { name: 'param2', value: 'true' }, ], toolVersion: 1, }; @@ -128,7 +128,7 @@ describe('ToolContextController (API)', () => { contextType: postParams.contextType, parameters: [ { name: 'param1', value: 'value' }, - { name: 'param2', value: undefined }, + { name: 'param2', value: 'true' }, ], toolVersion: postParams.toolVersion, }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts index 41e849b3d79..961ca2a8423 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ValidationError } from '@mikro-orm/core'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ValidationError } from '@shared/common'; import { contextExternalToolFactory, externalToolFactory } from '@shared/testing'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; @@ -70,6 +70,7 @@ describe('ContextExternalToolValidationService', () => { contextExternalToolService.findContextExternalTools.mockResolvedValue([ contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), ]); + commonToolValidationService.validateParameters.mockReturnValue([]); return { externalTool, @@ -101,10 +102,7 @@ describe('ContextExternalToolValidationService', () => { await service.validate(contextExternalTool); - expect(commonToolValidationService.checkCustomParameterEntries).toBeCalledWith( - externalTool, - contextExternalTool - ); + expect(commonToolValidationService.validateParameters).toBeCalledWith(externalTool, contextExternalTool); }); it('should not throw UnprocessableEntityException', async () => { @@ -167,5 +165,35 @@ describe('ContextExternalToolValidationService', () => { }); }); }); + + describe('when the parameter validation fails', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Tool 1', + }); + + const error: ValidationError = new ValidationError(''); + + externalToolService.findById.mockResolvedValue(externalTool); + contextExternalToolService.findContextExternalTools.mockResolvedValue([ + contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), + ]); + commonToolValidationService.validateParameters.mockReturnValue([error]); + + return { + externalTool, + contextExternalTool, + error, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, error } = setup(); + + await expect(service.validate(contextExternalTool)).rejects.toThrow(error); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index b12193eadd5..cd467efc5d5 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -26,7 +26,14 @@ export class ContextExternalToolValidationService { const loadedExternalTool: ExternalTool = await this.externalToolService.findById(loadedSchoolExternalTool.toolId); - this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, contextExternalTool); + const errors: ValidationError[] = this.commonToolValidationService.validateParameters( + loadedExternalTool, + contextExternalTool + ); + + if (errors.length) { + throw errors[0]; + } } private async checkDuplicateUsesInContext(contextExternalTool: ContextExternalTool) { 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 a05a3bd7370..18e68c4cd3e 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 @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ContextExternalToolRepo } from '@shared/repo'; import { contextExternalToolFactory, + customParameterFactory, externalToolFactory, legacySchoolDoFactory, schoolExternalToolFactory, @@ -20,6 +21,7 @@ import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { RestrictedContextMismatchLoggable } from './restricted-context-mismatch-loggabble'; import { CommonToolService } from '../../common/service'; +import { CustomParameter } from '../../common/domain'; describe('ContextExternalToolService', () => { let module: TestingModule; @@ -400,4 +402,100 @@ describe('ContextExternalToolService', () => { }); }); }); + + describe('copyContextExternalTool', () => { + const setup = () => { + const courseId: string = new ObjectId().toHexString(); + const contextCopyId: string = new ObjectId().toHexString(); + + const protectedParam: CustomParameter = customParameterFactory.build({ isProtected: true }); + const unprotectedParam: CustomParameter = customParameterFactory.build(); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + parameters: [protectedParam, unprotectedParam], + }); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id }); + + const unusedParam: CustomParameter = customParameterFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + contextRef: { type: ToolContextType.COURSE, id: courseId }, + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: schoolExternalTool.schoolId }, + parameters: [ + { name: protectedParam.name, value: 'paramValue1' }, + { name: unprotectedParam.name, value: 'paramValue2' }, + { name: unusedParam.name, value: 'paramValue3' }, + ], + }); + + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); + jest + .spyOn(contextExternalToolRepo, 'save') + .mockImplementation((tool: ContextExternalTool) => Promise.resolve(tool)); + + return { + contextCopyId, + contextExternalTool, + schoolExternalTool, + unusedParam, + }; + }; + + it('should find schoolExternalTool', async () => { + const { contextExternalTool, contextCopyId } = setup(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId); + + expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); + }); + + it('should find externalTool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId); + + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); + }); + + it('should remove values from protected parameters', async () => { + const { contextExternalTool, contextCopyId } = setup(); + + const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); + + expect(copiedTool).toEqual>({ + id: undefined, + contextRef: { id: contextCopyId, type: ToolContextType.COURSE }, + displayName: contextExternalTool.displayName, + schoolToolRef: contextExternalTool.schoolToolRef, + toolVersion: contextExternalTool.toolVersion, + parameters: [ + { + name: contextExternalTool.parameters[0].name, + value: undefined, + }, + { + name: contextExternalTool.parameters[1].name, + value: contextExternalTool.parameters[1].value, + }, + ], + }); + }); + + it('should not copy unused parameter', async () => { + const { contextExternalTool, contextCopyId, unusedParam } = setup(); + + const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); + + expect(copiedTool.parameters.length).toEqual(2); + expect(copiedTool.parameters).not.toContain(unusedParam); + }); + + it('should save copied tool', async () => { + const { contextExternalTool, contextCopyId } = setup(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId); + + expect(contextExternalToolRepo.save).toHaveBeenCalledWith(contextExternalTool); + }); + }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts index baf6bd82b06..bc2be7edfc1 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 @@ -9,6 +9,7 @@ import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { RestrictedContextMismatchLoggable } from './restricted-context-mismatch-loggabble'; import { CommonToolService } from '../../common/service'; +import { CustomParameter, CustomParameterEntry } from '../../common/domain'; @Injectable() export class ContextExternalToolService { @@ -76,4 +77,60 @@ export class ContextExternalToolService { throw new RestrictedContextMismatchLoggable(externalTool.name, contextExternalTool.contextRef.type); } } + + public async copyContextExternalTool( + contextExternalTool: ContextExternalTool, + contextCopyId: EntityId + ): Promise { + contextExternalTool.id = undefined; + contextExternalTool.contextRef.id = contextCopyId; + + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + + contextExternalTool.parameters.forEach((parameter: CustomParameterEntry): void => { + const isUnusedParameter = !externalTool.parameters?.find( + (param: CustomParameter): boolean => param.name === parameter.name + ); + if (isUnusedParameter) { + this.deleteUnusedParameter(contextExternalTool, parameter.name); + } + }); + + externalTool.parameters?.forEach((parameter: CustomParameter): void => { + if (parameter.isProtected) { + this.deleteProtectedValues(contextExternalTool, parameter.name); + } + }); + + const copiedTool = await this.contextExternalToolRepo.save(contextExternalTool); + + return copiedTool; + } + + private deleteUnusedParameter(contextExternalTool: ContextExternalTool, unusedParameterName: string): void { + const unusedParameter: CustomParameterEntry | undefined = contextExternalTool.parameters.find( + (param: CustomParameterEntry): boolean => param.name === unusedParameterName + ); + + if (unusedParameter) { + const unusedParameterIndex: number = contextExternalTool.parameters.indexOf({ + name: unusedParameter.name, + value: unusedParameter.value, + }); + contextExternalTool.parameters.splice(unusedParameterIndex, 1); + } + } + + private deleteProtectedValues(contextExternalTool: ContextExternalTool, protectedParameterName: string): void { + const protectedParameter: CustomParameterEntry | undefined = contextExternalTool.parameters.find( + (param: CustomParameterEntry): boolean => param.name === protectedParameterName + ); + + if (protectedParameter) { + protectedParameter.value = undefined; + } + } } diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts index d9e3e1a9c4a..f3c31e6e506 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts @@ -83,7 +83,7 @@ describe('ToolReferenceService', () => { contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); - toolVersionService.determineToolConfigurationStatus.mockResolvedValue( + toolVersionService.determineToolConfigurationStatus.mockReturnValue( toolConfigurationStatusFactory.build({ isOutdatedOnScopeSchool: true, isOutdatedOnScopeContext: false, diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts index 39894db0aa1..46ae330e5a0 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; +import { ContextExternalToolConfigurationStatus } from '../../common/domain'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; -import { ContextExternalToolConfigurationStatus } from '../../common/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool, ToolReference } from '../domain'; @@ -29,12 +29,11 @@ export class ToolReferenceService { ); const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); - const status: ContextExternalToolConfigurationStatus = - await this.toolVersionService.determineToolConfigurationStatus( - externalTool, - schoolExternalTool, - contextExternalTool - ); + const status: ContextExternalToolConfigurationStatus = this.toolVersionService.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); const toolReference: ToolReference = ToolReferenceMapper.mapToToolReference( externalTool, diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts index f55651f738b..b9bf9c86184 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts @@ -1,26 +1,26 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApiValidationError } from '@shared/common'; +import { ValidationError } from '@shared/common'; import { contextExternalToolFactory, + customParameterFactory, externalToolFactory, schoolExternalToolFactory, - schoolToolConfigurationStatusFactory, - toolConfigurationStatusFactory, } from '@shared/testing'; -import { ContextExternalToolConfigurationStatus } from '../../common/domain'; -import { CommonToolService } from '../../common/service'; -import { SchoolExternalToolValidationService } from '../../school-external-tool/service'; +import { + ContextExternalToolConfigurationStatus, + ToolParameterDuplicateLoggableException, + ToolParameterValueMissingLoggableException, +} from '../../common/domain'; +import { CommonToolService, CommonToolValidationService } from '../../common/service'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; -import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; import { ToolVersionService } from './tool-version-service'; describe('ToolVersionService', () => { let module: TestingModule; let service: ToolVersionService; - let contextExternalToolValidationService: DeepMocked; - let schoolExternalToolValidationService: DeepMocked; + let commonToolValidationService: DeepMocked; let commonToolService: DeepMocked; let toolFeatures: DeepMocked; @@ -29,12 +29,8 @@ describe('ToolVersionService', () => { providers: [ ToolVersionService, { - provide: ContextExternalToolValidationService, - useValue: createMock(), - }, - { - provide: SchoolExternalToolValidationService, - useValue: createMock(), + provide: CommonToolValidationService, + useValue: createMock(), }, { provide: CommonToolService, @@ -50,8 +46,7 @@ describe('ToolVersionService', () => { }).compile(); service = module.get(ToolVersionService); - contextExternalToolValidationService = module.get(ContextExternalToolValidationService); - schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); + commonToolValidationService = module.get(CommonToolValidationService); commonToolService = module.get(CommonToolService); toolFeatures = module.get(ToolFeatures); }); @@ -84,10 +79,10 @@ describe('ToolVersionService', () => { }; }; - it('should call CommonToolService', async () => { + it('should call CommonToolService', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( externalTool, @@ -109,8 +104,7 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockResolvedValue(); - contextExternalToolValidationService.validate.mockResolvedValueOnce(); + commonToolValidationService.validateParameters.mockReturnValue([]); return { externalTool, @@ -119,37 +113,37 @@ describe('ToolVersionService', () => { }; }; - it('should return latest tool status', async () => { + it('should return latest tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: false, - isOutdatedOnScopeSchool: false, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, + isIncompleteOnScopeContext: false, + isDeactivated: false, + }); }); - it('should call schoolExternalToolValidationService', async () => { + it('should validate the school external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, schoolExternalTool); }); - it('should call contextExternalToolValidationService', async () => { + it('should validate the context external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, contextExternalTool); }); }); @@ -165,8 +159,8 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockRejectedValueOnce(ApiValidationError); - contextExternalToolValidationService.validate.mockResolvedValue(); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); + commonToolValidationService.validateParameters.mockReturnValueOnce([]); return { externalTool, @@ -175,37 +169,37 @@ describe('ToolVersionService', () => { }; }; - it('should return outdated tool status', async () => { + it('should return outdated tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: false, - isOutdatedOnScopeSchool: true, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: true, + isOutdatedOnScopeContext: false, + isIncompleteOnScopeContext: false, + isDeactivated: false, + }); }); - it('should call schoolExternalToolValidationService', async () => { + it('should validate the school external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, schoolExternalTool); }); - it('should call contextExternalToolValidationService', async () => { + it('should validate the context external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, contextExternalTool); }); }); @@ -221,8 +215,8 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockResolvedValue(); - contextExternalToolValidationService.validate.mockRejectedValueOnce(ApiValidationError); + commonToolValidationService.validateParameters.mockReturnValueOnce([]); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); return { externalTool, @@ -231,37 +225,37 @@ describe('ToolVersionService', () => { }; }; - it('should return outdated tool status', async () => { + it('should return outdated tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: false, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: false, + isDeactivated: false, + }); }); - it('should call schoolExternalToolValidationService', async () => { + it('should validate the school external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, schoolExternalTool); }); - it('should call contextExternalToolValidationService', async () => { + it('should validate the context external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, contextExternalTool); }); }); @@ -277,8 +271,8 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockRejectedValueOnce(ApiValidationError); - contextExternalToolValidationService.validate.mockRejectedValueOnce(ApiValidationError); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); return { externalTool, @@ -287,37 +281,81 @@ describe('ToolVersionService', () => { }; }; - it('should return outdated tool status', async () => { + it('should return outdated tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: true, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: true, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: false, + isDeactivated: false, + }); + }); + + it('should validate the school external tool', () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, schoolExternalTool); }); - it('should call schoolExternalToolValidationService', async () => { + it('should validate the context external tool', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, contextExternalTool); }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and validation of ContextExternalTool throws at least 1 missing value errors', () => { + const setup = () => { + const customParameter = customParameterFactory.build(); + const externalTool = externalToolFactory.buildWithId({ parameters: [customParameter] }); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + commonToolValidationService.validateParameters.mockReturnValueOnce([]); + commonToolValidationService.validateParameters.mockReturnValueOnce([ + new ToolParameterValueMissingLoggableException(customParameter), + new ToolParameterDuplicateLoggableException(customParameter.name), + ]); - it('should call contextExternalToolValidationService', async () => { + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return incomplete as tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); - expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); + expect(status).toEqual({ + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: true, + isDeactivated: false, + }); }); }); @@ -326,16 +364,16 @@ describe('ToolVersionService', () => { const externalTool = externalToolFactory.buildWithId(); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id as string, + status: { isDeactivated: true }, }); - schoolExternalTool.status = schoolToolConfigurationStatusFactory.build({ isDeactivated: true }); const contextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id as string) .buildWithId(); toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); - contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); return { externalTool, @@ -344,30 +382,27 @@ describe('ToolVersionService', () => { }; }; - it('should return status is deactivated', async () => { + it('should return status is deactivated', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: true, - isDeactivated: true, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: true, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: false, + isDeactivated: true, + }); }); }); describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and externalTool is deactivated', () => { const setup = () => { - const externalTool = externalToolFactory.buildWithId({ - isDeactivated: true, - }); + const externalTool = externalToolFactory.buildWithId({ isDeactivated: true }); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id as string, }); @@ -377,8 +412,8 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); - contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); return { externalTool, @@ -387,29 +422,27 @@ describe('ToolVersionService', () => { }; }; - it('should return deactivated tool status', async () => { + it('should return deactivated tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: true, - isDeactivated: true, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: true, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: false, + isDeactivated: true, + }); }); }); describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true, externalTool and schoolExternalTool are not deactivated', () => { const setup = () => { - const externalTool = externalToolFactory.buildWithId({}); - + const externalTool = externalToolFactory.buildWithId(); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id as string, }); @@ -419,8 +452,8 @@ describe('ToolVersionService', () => { toolFeatures.toolStatusWithoutVersions = true; - schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); - contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); return { externalTool, @@ -429,22 +462,21 @@ describe('ToolVersionService', () => { }; }; - it('should return deactivated tool status', async () => { + it('should return deactivated tool status', () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + const status: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); - expect(status).toEqual( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: true, - isDeactivated: false, - }) - ); + expect(status).toEqual({ + isOutdatedOnScopeSchool: true, + isOutdatedOnScopeContext: true, + isIncompleteOnScopeContext: false, + isDeactivated: false, + }); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts index afe8110a88a..06677a4b60a 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts @@ -1,50 +1,67 @@ import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { ContextExternalToolConfigurationStatus } from '../../common/domain'; -import { CommonToolService } from '../../common/service'; +import { ValidationError } from '@shared/common'; +import { + ContextExternalToolConfigurationStatus, + ToolParameterValueMissingLoggableException, +} from '../../common/domain'; +import { CommonToolService, CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { SchoolExternalToolValidationService } from '../../school-external-tool/service'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ContextExternalTool } from '../domain'; -import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; @Injectable() export class ToolVersionService { constructor( - private readonly contextExternalToolValidationService: ContextExternalToolValidationService, - private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, private readonly commonToolService: CommonToolService, + private readonly commonToolValidationService: CommonToolValidationService, @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures ) {} - async determineToolConfigurationStatus( + public determineToolConfigurationStatus( externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, contextExternalTool: ContextExternalTool - ): Promise { + ): ContextExternalToolConfigurationStatus { // TODO N21-1337 remove if statement, when feature flag is removed if (this.toolFeatures.toolStatusWithoutVersions) { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: false, + isIncompleteOnScopeContext: false, isOutdatedOnScopeSchool: false, isDeactivated: this.isToolDeactivated(externalTool, schoolExternalTool), }); - try { - await this.schoolExternalToolValidationService.validate(schoolExternalTool); - } catch (err) { + const schoolParameterErrors: ValidationError[] = this.commonToolValidationService.validateParameters( + externalTool, + schoolExternalTool + ); + + if (schoolParameterErrors.length) { configurationStatus.isOutdatedOnScopeSchool = true; } - try { - await this.contextExternalToolValidationService.validate(contextExternalTool); - } catch (err) { + const contextParameterErrors: ValidationError[] = this.commonToolValidationService.validateParameters( + externalTool, + contextExternalTool + ); + + if (contextParameterErrors.length) { configurationStatus.isOutdatedOnScopeContext = true; + + if ( + contextParameterErrors.some( + (error: ValidationError) => error instanceof ToolParameterValueMissingLoggableException + ) + ) { + configurationStatus.isIncompleteOnScopeContext = true; + } } return configurationStatus; } + const status: ContextExternalToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( externalTool, schoolExternalTool, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts index b982f4cbf0a..fc02e6d4426 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts @@ -17,7 +17,6 @@ describe('ExternalToolParameterValidationService', () => { let service: ExternalToolParameterValidationService; let externalToolService: DeepMocked; - let commonToolValidationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -36,9 +35,6 @@ describe('ExternalToolParameterValidationService', () => { service = module.get(ExternalToolParameterValidationService); externalToolService = module.get(ExternalToolService); - commonToolValidationService = module.get(CommonToolValidationService); - - commonToolValidationService.isValueValidForType.mockReturnValue(true); }); afterAll(async () => { @@ -342,7 +338,6 @@ describe('ExternalToolParameterValidationService', () => { const externalTool: ExternalTool = externalToolFactory.buildWithId({ parameters: [parameter] }); externalToolService.findExternalToolByName.mockResolvedValue(externalTool); - commonToolValidationService.isValueValidForType.mockReturnValue(false); return { externalTool, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts index 87690959305..123efd0b165 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts @@ -2,16 +2,13 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { CustomParameter } from '../../common/domain'; import { autoParameters, CustomParameterScope } from '../../common/enum'; -import { CommonToolValidationService } from '../../common/service'; +import { ToolParameterTypeValidationUtil } from '../../common/service'; import { ExternalTool } from '../domain'; import { ExternalToolService } from './external-tool.service'; @Injectable() export class ExternalToolParameterValidationService { - constructor( - private readonly externalToolService: ExternalToolService, - private readonly commonToolValidationService: CommonToolValidationService - ) {} + constructor(private readonly externalToolService: ExternalToolService) {} async validateCommon(externalTool: ExternalTool | Partial): Promise { if (!(await this.isNameUnique(externalTool))) { @@ -117,7 +114,7 @@ export class ExternalToolParameterValidationService { private isDefaultValueOfValidType(param: CustomParameter): boolean { if (param.default) { - const isValid: boolean = this.commonToolValidationService.isValueValidForType(param.type, param.default); + const isValid: boolean = ToolParameterTypeValidationUtil.isValueValidForType(param.type, param.default); return isValid; } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 04c9d0d7b5d..7a80650eb76 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -94,7 +94,7 @@ describe('ToolSchoolController (API)', () => { version: 1, parameters: [ { name: 'param1', value: 'value' }, - { name: 'param2', value: '' }, + { name: 'param2', value: 'false' }, ], isDeactivated: false, }; @@ -148,7 +148,7 @@ describe('ToolSchoolController (API)', () => { toolVersion: postParams.version, parameters: [ { name: 'param1', value: 'value' }, - { name: 'param2', value: undefined }, + { name: 'param2', value: 'false' }, ], }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts index 1f2ba7f5eb9..f7e4d5687e4 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { externalToolFactory, schoolExternalToolFactory } from '@shared/testing/factory/domainobject/tool'; +import { ValidationError } from '@shared/common'; +import { externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; @@ -8,7 +9,7 @@ import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; -describe('SchoolExternalToolValidationService', () => { +describe(SchoolExternalToolValidationService.name, () => { let module: TestingModule; let service: SchoolExternalToolValidationService; @@ -48,32 +49,21 @@ describe('SchoolExternalToolValidationService', () => { }); describe('validate', () => { - const setup = ( - externalToolDoMock?: Partial, - schoolExternalToolDoMock?: Partial - ) => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - ...schoolExternalToolFactory.buildWithId(), - ...schoolExternalToolDoMock, - }); - const externalTool: ExternalTool = new ExternalTool({ - ...externalToolFactory.buildWithId(), - ...externalToolDoMock, - }); - - const schoolExternalToolId = schoolExternalTool.id as string; + describe('when the schoolExternalTool is valid', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1337 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 8383 }); - externalToolService.findById.mockResolvedValue(externalTool); - toolFeatures.toolStatusWithoutVersions = true; + externalToolService.findById.mockResolvedValue(externalTool); + commonToolValidationService.validateParameters.mockReturnValueOnce([]); + toolFeatures.toolStatusWithoutVersions = true; - return { - schoolExternalTool, - ExternalTool, - schoolExternalToolId, + return { + schoolExternalTool, + externalTool, + }; }; - }; - describe('when schoolExternalTool is given', () => { it('should call externalToolService.findExternalToolById', async () => { const { schoolExternalTool } = setup(); @@ -83,22 +73,41 @@ describe('SchoolExternalToolValidationService', () => { }); it('should call commonToolValidationService.checkCustomParameterEntries', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool, externalTool } = setup(); await service.validate(schoolExternalTool); - expect(commonToolValidationService.checkCustomParameterEntries).toHaveBeenCalledWith( - expect.anything(), - schoolExternalTool - ); + expect(commonToolValidationService.validateParameters).toHaveBeenCalledWith(externalTool, schoolExternalTool); }); it('should not throw error', async () => { - const { schoolExternalTool } = setup({ version: 8383 }, { toolVersion: 1337 }); + const { schoolExternalTool } = setup(); - const func = () => service.validate(schoolExternalTool); + await expect(service.validate(schoolExternalTool)).resolves.not.toThrow(); + }); + }); + + describe('when the schoolExternalTool is invalid', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const error: ValidationError = new ValidationError(''); + + externalToolService.findById.mockResolvedValue(externalTool); + commonToolValidationService.validateParameters.mockReturnValueOnce([error]); + toolFeatures.toolStatusWithoutVersions = true; - await expect(func()).resolves.not.toThrow(); + return { + schoolExternalTool, + externalTool, + error, + }; + }; + + it('should throw an error', async () => { + const { schoolExternalTool, error } = setup(); + + await expect(service.validate(schoolExternalTool)).rejects.toThrow(error); }); }); }); @@ -106,37 +115,24 @@ describe('SchoolExternalToolValidationService', () => { // TODO N21-1337 refactor after feature flag is removed describe('validate with FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED on false', () => { describe('when version of externalTool and schoolExternalTool are different', () => { - const setup = ( - externalToolDoMock?: Partial, - schoolExternalToolDoMock?: Partial - ) => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - ...schoolExternalToolFactory.buildWithId(), - ...schoolExternalToolDoMock, - }); - const externalTool: ExternalTool = new ExternalTool({ - ...externalToolFactory.buildWithId(), - ...externalToolDoMock, - }); - - const schoolExternalToolId = schoolExternalTool.id as string; + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ toolVersion: 1337 }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 8383 }); externalToolService.findById.mockResolvedValue(externalTool); toolFeatures.toolStatusWithoutVersions = false; return { schoolExternalTool, - ExternalTool, - schoolExternalToolId, }; }; it('should throw error', async () => { - const { schoolExternalTool } = setup({ version: 8383 }, { toolVersion: 1337 }); + const { schoolExternalTool } = setup(); const func = () => service.validate(schoolExternalTool); - await expect(func()).rejects.toThrowError('tool_version_mismatch:'); + await expect(func()).rejects.toThrowError('tool_version_mismatch'); }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts index 899055e321f..17ac81faf10 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts @@ -21,7 +21,14 @@ export class SchoolExternalToolValidationService { this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); } - this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, schoolExternalTool); + const errors: ValidationError[] = this.commonToolValidationService.validateParameters( + loadedExternalTool, + schoolExternalTool + ); + + if (errors.length) { + throw errors[0]; + } } private checkVersionMatch(schoolExternalToolVersion: number, externalToolVersion: number): void { diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index 1405f0c7a1d..a7ee1f65d8a 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -10,6 +10,7 @@ export interface IToolFeatures { toolStatusWithoutVersions: boolean; maxExternalToolLogoSizeInBytes: number; backEndUrl: string; + ctlToolsCopyEnabled: boolean; } export default class ToolConfiguration { @@ -21,5 +22,6 @@ export default class ToolConfiguration { toolStatusWithoutVersions: Configuration.get('FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED') as boolean, maxExternalToolLogoSizeInBytes: Configuration.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') as number, backEndUrl: Configuration.get('PUBLIC_BACKEND_URL') as string, + ctlToolsCopyEnabled: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean, }; } diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 30ce3c20cbf..944c9b43f4e 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -108,10 +108,11 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce( + toolVersionService.determineToolConfigurationStatus.mockReturnValueOnce( toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isIncompleteOnScopeContext: false, }) ); @@ -179,10 +180,11 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); - toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce( + toolVersionService.determineToolConfigurationStatus.mockReturnValueOnce( toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isIncompleteOnScopeContext: false, }) ); @@ -229,10 +231,11 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce( + toolVersionService.determineToolConfigurationStatus.mockReturnValueOnce( toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isIncompleteOnScopeContext: false, isDeactivated: true, }) ); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index 8378926a107..261cd2cce94 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -54,7 +54,7 @@ export class ToolLaunchService { const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - await this.isToolStatusLaunchableOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); + this.isToolStatusLaunchableOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); const strategy: ToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); @@ -84,18 +84,17 @@ export class ToolLaunchService { }; } - private async isToolStatusLaunchableOrThrow( + private isToolStatusLaunchableOrThrow( userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, contextExternalTool: ContextExternalTool - ): Promise { - const status: ContextExternalToolConfigurationStatus = - await this.toolVersionService.determineToolConfigurationStatus( - externalTool, - schoolExternalTool, - contextExternalTool - ); + ): void { + const status: ContextExternalToolConfigurationStatus = this.toolVersionService.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); if (status.isOutdatedOnScopeSchool || status.isOutdatedOnScopeContext || status.isDeactivated) { throw new ToolStatusOutdatedLoggableException( diff --git a/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts b/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts index 58dc7a5a01c..9954fd3797f 100644 --- a/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts +++ b/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts @@ -6,6 +6,7 @@ export const contextExternalToolConfigurationStatusResponseFactory = return { isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isIncompleteOnScopeContext: false, isDeactivated: false, }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts index 75458a32094..838c57978c6 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts @@ -5,6 +5,7 @@ export const toolConfigurationStatusFactory = Factory.define