diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 44a0644b141..8a8b06c219e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,4 +13,5 @@ jobs: - name: 'Dependency Review' uses: actions/dependency-review-action@v3 with: - allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0, Unlicense + allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0 AND BSD-3-Clause-Clear, Unlicense + allow-dependencies-licenses: 'pkg:npm/parse-mongo-url' diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a5688568696..c042be2c2a9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -172,7 +172,7 @@ jobs: uses: github/codeql-action/upload-sarif@v2 with: sarif_file: 'trivy-results.sarif' - + end-to-end-tests: needs: - build_and_push diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 64e257c5f59..4840ec78474 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,6 +37,13 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API client secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: onepassword-admin-api-client.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: remove old migration Job kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -58,7 +65,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: deployment.yml.j2 - + - name: Ingress kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -108,6 +115,12 @@ namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + - name: Data deletion trigger CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: data-deletion-trigger-cronjob.yml.j2 + - name: AMQPFileStorageDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -142,3 +155,25 @@ when: - KEDA_ENABLED is defined and KEDA_ENABLED|bool - SCALED_PREVIEW_GENERATOR_ENABLED is defined and SCALED_PREVIEW_GENERATOR_ENABLED|bool + + - name: TlDraw server deployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-deployment.yml.j2 + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + + - name: TlDraw server service + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-server-svc.yml.j2 + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + + - name: Tldraw ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-ingress.yml.j2 + apply: yes + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool diff --git a/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 new file mode 100644 index 00000000000..a0807973c43 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 @@ -0,0 +1,57 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: data-deletion-trigger-cronjob +spec: + concurrencyPolicy: Forbid + schedule: "{{ SERVER_DATA_DELETION_TRIGGER_CRONJOB_SCHEDULE|default("@hourly", true) }}" + jobTemplate: + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + template: + spec: + containers: + - name: data-deletion-trigger-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - secretRef: + name: admin-api-client-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:deletion-console -- execution trigger'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 new file mode 100644 index 00000000000..fe2be1d76a8 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 @@ -0,0 +1,7 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: admin-api-client-secret + namespace: {{ NAMESPACE }} +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/admin-api-client" diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 new file mode 100644 index 00000000000..f9dc4f09d9e --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tldraw-deployment + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-server + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} +spec: + replicas: {{ TLDRAW_SERVER_REPLICAS|default("1", true) }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + #maxUnavailable: 1 + revisionHistoryLimit: 4 + paused: false + selector: + matchLabels: + app: tldraw-server + template: + metadata: + labels: + app: tldraw-server + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-server + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true + containers: + - name: tldraw + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3345 + name: tldraw-ws + protocol: TCP + - containerPort: 3349 + name: tldraw-http + protocol: TCP + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + command: ['npm', 'run', 'nest:start:tldraw:prod'] + resources: + limits: + cpu: {{ TLDRAW_EDITOR_CPU_LIMITS|default("2000m", true) }} + memory: {{ TLDRAW_EDITOR_MEMORY_LIMITS|default("4Gi", true) }} + requests: + cpu: {{ TLDRAW_EDITOR_CPU_REQUESTS|default("100m", true) }} + memory: {{ TLDRAW_EDITOR_MEMORY_REQUESTS|default("150Mi", true) }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 new file mode 100644 index 00000000000..e80028a5985 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ NAMESPACE }}-tldraw-ingress + namespace: {{ NAMESPACE }} + annotations: + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + # The following properties added with BC-3606. + # The header size of the request is too big. For e.g. state and the permanent growing jwt. + # Nginx throws away the Location header, resulting in the 502 Bad Gateway. + nginx.ingress.kubernetes.io/client-header-buffer-size: 100k + nginx.ingress.kubernetes.io/http2-max-header-size: 96k + nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k + nginx.ingress.kubernetes.io/proxy-buffer-size: 96k +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} + +spec: + ingressClassName: nginx +{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} + tls: + - hosts: + - {{ DOMAIN }} +{% if CLUSTER_ISSUER is defined %} + secretName: {{ DOMAIN }}-tls +{% endif %} +{% endif %} + rules: + - host: {{ DOMAIN }} + http: + paths: + - path: /tldraw-server + backend: + service: + name: tldraw-server-svc + port: + number: 3345 + pathType: Prefix diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 new file mode 100644 index 00000000000..8a1ded9a1d9 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: tldraw-server-svc + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server +spec: + type: ClusterIP + ports: + # port for WebSocket connection + - port: 3345 + targetPort: 3345 + protocol: TCP + name: tldraw-ws + # port for http managing drawing data + - port: 3349 + targetPort: 3349 + protocol: TCP + name: tldraw-http + selector: + app: tldraw-server diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 6322bcd568f..7f51ee9b92e 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -1,33 +1,34 @@ /* istanbul ignore file */ +import { Mail, MailService } from '@infra/mail'; // application imports /* eslint-disable no-console */ import { MikroORM } from '@mikro-orm/core'; -import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { Mail, MailService } from '@infra/mail'; -import { LegacyLogger, Logger } from '@src/core/logger'; import { AccountService } from '@modules/account'; -import { TeamService } from '@modules/teams/service/team.service'; import { AccountValidationService } from '@modules/account/services/account.validation.service'; import { AccountUc } from '@modules/account/uc/account.uc'; +import { SystemRule } from '@modules/authorization/domain/rules'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; import { GroupService } from '@modules/group'; +import { FeathersRosterService } from '@modules/pseudonym'; import { RocketChatService } from '@modules/rocketchat'; import { ServerModule } from '@modules/server'; +import { TeamService } from '@modules/teams/service/team.service'; +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { LegacyLogger, Logger } from '@src/core/logger'; import express from 'express'; import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; -import { FeathersRosterService } from '@modules/pseudonym'; -import legacyAppPromise = require('../../../../src/app'); import { AppStartLoggable } from './helpers/app-start-loggable'; import { addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, } from './helpers/prometheus-metrics'; +import legacyAppPromise = require('../../../../src/app'); async function bootstrap() { sourceMapInstall(); @@ -85,6 +86,8 @@ async function bootstrap() { feathersExpress.services['nest-feathers-roster-service'] = nestApp.get(FeathersRosterService); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-group-service'] = nestApp.get(GroupService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + feathersExpress.services['nest-system-rule'] = nestApp.get(SystemRule); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts new file mode 100644 index 00000000000..a394b1e8deb --- /dev/null +++ b/apps/server/src/apps/tldraw.app.ts @@ -0,0 +1,50 @@ +/* istanbul ignore file */ +/* eslint-disable no-console */ +import { NestFactory } from '@nestjs/core'; +import { install as sourceMapInstall } from 'source-map-support'; +import { TldrawModule, TldrawWsModule } from '@modules/tldraw'; +import { LegacyLogger, Logger } from '@src/core/logger'; +import * as WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import express from 'express'; + +async function bootstrap() { + sourceMapInstall(); + + const nestExpress = express(); + const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(TldrawModule, nestExpressAdapter); + nestApp.useLogger(await nestApp.resolve(LegacyLogger)); + nestApp.enableCors(); + + const nestAppWS = await NestFactory.create(TldrawWsModule); + const wss = new WebSocket.Server({ noServer: true }); + nestAppWS.useWebSocketAdapter(new WsAdapter(wss)); + nestAppWS.enableCors(); + enableOpenApiDocs(nestAppWS, 'docs'); + const logger = await nestAppWS.resolve(Logger); + + await nestAppWS.init(); + await nestApp.init(); + + // mount instances + const rootExpress = express(); + + const port = 3349; + const basePath = '/api/v3'; + + // exposed alias mounts + rootExpress.use(basePath, nestExpress); + rootExpress.listen(port); + + logger.info( + new AppStartLoggable({ + appName: 'Tldraw server app', + }) + ); +} + +void bootstrap(); diff --git a/apps/server/src/config/database.config.ts b/apps/server/src/config/database.config.ts index ad97e4c3d66..17c45dd1887 100644 --- a/apps/server/src/config/database.config.ts +++ b/apps/server/src/config/database.config.ts @@ -4,9 +4,10 @@ interface GlobalConstants { DB_URL: string; DB_PASSWORD?: string; DB_USERNAME?: string; + TLDRAW_DB_URL: string; } const usedGlobals: GlobalConstants = globals; /** Database URL */ -export const { DB_URL, DB_PASSWORD, DB_USERNAME } = usedGlobals; +export const { DB_URL, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } = usedGlobals; diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts index 98ed3552918..bae96a2d119 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts @@ -1,14 +1,14 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import AuthenticationExecutionExportRepresentation from '@keycloak/keycloak-admin-client/lib/defs/authenticationExecutionExportRepresentation'; import AuthenticationFlowRepresentation from '@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation'; +import { LegacySystemService } from '@modules/system'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { SystemRepo } from '@shared/repo/system/system.repo'; -import { systemFactory } from '@shared/testing/factory'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing/factory'; import { LoggerModule } from '@src/core/logger'; -import { SystemService } from '@modules/system/service/system.service'; import { v1 } from 'uuid'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; import { KeycloakConfigurationModule } from '../keycloak-configuration.module'; @@ -17,15 +17,15 @@ import { KeycloakConfigurationService } from './keycloak-configuration.service'; describe('KeycloakConfigurationService Integration', () => { let module: TestingModule; let keycloak: KeycloakAdminClient; - let systemRepo: SystemRepo; + let systemRepo: LegacySystemRepo; let keycloakAdministrationService: KeycloakAdministrationService; let keycloakConfigurationService: KeycloakConfigurationService; let isKeycloakAvailable = false; const testRealm = `test-realm-${v1().toString()}`; const flowAlias = 'Direct Broker Flow'; - const systemServiceMock = createMock(); - const systems = systemFactory.withOidcConfig().buildList(1); + const systemServiceMock = createMock(); + const systems = systemEntityFactory.withOidcConfig().buildList(1); beforeAll(async () => { module = await Test.createTestingModule({ @@ -38,9 +38,9 @@ describe('KeycloakConfigurationService Integration', () => { validationOptions: { infer: true }, }), ], - providers: [SystemRepo], + providers: [LegacySystemRepo], }).compile(); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); keycloakAdministrationService = module.get(KeycloakAdministrationService); keycloakConfigurationService = module.get(KeycloakConfigurationService); isKeycloakAvailable = await keycloakAdministrationService.testKcConnection(); diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts index 0b47e876ba0..76bcbceebf2 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts @@ -13,7 +13,7 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { v1 } from 'uuid'; @@ -63,7 +63,9 @@ describe('KeycloakConfigurationService Unit', () => { }; }; - const systems: SystemEntity[] = systemFactory.withOidcConfig().buildListWithId(1, { type: SystemTypeEnum.OIDC }); + const systems: SystemEntity[] = systemEntityFactory + .withOidcConfig() + .buildListWithId(1, { type: SystemTypeEnum.OIDC }); const oidcSystems = SystemOidcMapper.mapFromEntitiesToDtos(systems); const idps: IdentityProviderRepresentation[] = [ { diff --git a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts index cbfa289165e..b6458640c75 100644 --- a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts @@ -1,5 +1,5 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; -import { OauthConfigDto } from '@modules/system/service'; +import { OauthConfigDto } from '@modules/system/service/dto'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 9a57ba37a44..2a60d4c453a 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -2,7 +2,7 @@ import { IdentityManagementModule } from '@infra/identity-management'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PermissionService } from '@shared/domain'; -import { SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger/logger.module'; import { ServerConfig } from '../server/server.config'; import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb, AccountIdmToDtoMapperIdm } from './mapper'; @@ -24,7 +24,7 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { mockOtherTeacherAccount = accountFactory.buildWithId({ userId: mockOtherTeacherUser.id, }); - const externalSystemA = systemFactory.buildWithId(); - const externalSystemB = systemFactory.buildWithId(); + const externalSystemA = systemEntityFactory.buildWithId(); + const externalSystemB = systemEntityFactory.buildWithId(); mockExternalUserAccount = accountFactory.buildWithId({ userId: mockExternalUser.id, username: 'unique.within@system', diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index 6810c2baa5c..2552469b8cf 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -20,7 +20,7 @@ import { User, } from '@shared/domain'; import { UserRepo } from '@shared/repo'; -import { accountFactory, schoolFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { accountFactory, schoolFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; import { BruteForcePrevention } from '@src/imports-from-feathers'; import { ObjectId } from 'bson'; import { @@ -431,7 +431,7 @@ describe('AccountUc', () => { userId: mockUnknownRoleUser.id, password: defaultPasswordHash, }); - const externalSystem = systemFactory.buildWithId(); + const externalSystem = systemEntityFactory.buildWithId(); mockExternalUserAccount = accountFactory.buildWithId({ userId: mockExternalUser.id, password: defaultPasswordHash, @@ -440,25 +440,25 @@ describe('AccountUc', () => { mockAccountWithoutUser = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, }); mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); mockAccountWithLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: new Date(), }); mockAccountWithOldLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: new Date(new Date().getTime() - LOGIN_BLOCK_TIME - 1), }); mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: undefined, }); diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index 8f2bdcd3b0d..ca9872f75e0 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -1,14 +1,14 @@ -import { Module } from '@nestjs/common'; -import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; import { CacheWrapperModule } from '@infra/cache'; import { IdentityManagementModule } from '@infra/identity-management'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { AccountModule } from '@modules/account'; import { OauthModule } from '@modules/oauth/oauth.module'; import { RoleModule } from '@modules/role'; import { SystemModule } from '@modules/system'; +import { Module } from '@nestjs/common'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { Algorithm, SignOptions } from 'jsonwebtoken'; import { jwtConstants } from './constants'; import { AuthenticationService } from './services/authentication.service'; @@ -69,7 +69,7 @@ const jwtModuleOptions: JwtModuleOptions = { JwtStrategy, JwtValidationAdapter, UserRepo, - SystemRepo, + LegacySystemRepo, LegacySchoolRepo, LocalStrategy, AuthenticationService, diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 04683e182a8..6541da27b08 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -5,7 +5,7 @@ import { ServerTestModule } from '@modules/server/server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, RoleName, SchoolEntity, SystemEntity, User } from '@shared/domain'; -import { accountFactory, roleFactory, schoolFactory, systemFactory, userFactory } from '@shared/testing'; +import { accountFactory, roleFactory, schoolFactory, systemEntityFactory, userFactory } from '@shared/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; @@ -149,7 +149,7 @@ describe('Login Controller (api)', () => { describe('when user login succeeds', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); @@ -200,7 +200,7 @@ describe('Login Controller (api)', () => { describe('when user login fails', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); @@ -238,7 +238,7 @@ describe('Login Controller (api)', () => { describe('when logging in as a user of the Central LDAP of Brandenburg', () => { const setup = async () => { const officialSchoolNumber = '01234'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: officialSchoolNumber, @@ -301,7 +301,7 @@ describe('Login Controller (api)', () => { const schoolExternalId = 'schoolExternalId'; const userExternalId = 'userExternalId'; - const system = systemFactory.withOauthConfig().buildWithId({}); + const system = systemEntityFactory.withOauthConfig().buildWithId({}); const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); @@ -391,7 +391,7 @@ describe('Login Controller (api)', () => { const schoolExternalId = 'schoolExternalId'; const userExternalId = 'userExternalId'; - const system = systemFactory.withOauthConfig().buildWithId({}); + const system = systemEntityFactory.withOauthConfig().buildWithId({}); const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); diff --git a/apps/server/src/modules/authentication/services/ldap.service.spec.ts b/apps/server/src/modules/authentication/services/ldap.service.spec.ts index 8b334ac195c..ec0b8c05bb3 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.spec.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.spec.ts @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { LdapService } from './ldap.service'; @@ -59,7 +59,7 @@ describe('LdapService', () => { describe('checkLdapCredentials', () => { describe('when credentials are correct', () => { it('should login successfully', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect( ldapService.checkLdapCredentials(system, 'connectSucceeds', 'mockPassword') ).resolves.not.toThrow(); @@ -68,7 +68,7 @@ describe('LdapService', () => { describe('when no ldap config is provided', () => { it('should throw error', async () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( new Error(`no LDAP config found in system ${system.id}`) ); @@ -77,7 +77,7 @@ describe('LdapService', () => { describe('when user is not authorized', () => { it('should throw unauthorized error', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( new UnauthorizedException('User could not authenticate') ); @@ -86,7 +86,7 @@ describe('LdapService', () => { describe('when connected flag is not set', () => { it('should throw unauthorized error', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'connectWithoutFlag', 'mockPassword')).rejects.toThrow( new UnauthorizedException('User could not authenticate') ); diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index b3067de04eb..c45a25cc310 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -4,7 +4,7 @@ import { UnauthorizedException } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, RoleName, SystemEntity, User } from '@shared/domain'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { accountDtoFactory, defaultTestPassword, @@ -12,7 +12,7 @@ import { legacySchoolDoFactory, schoolFactory, setupEntities, - systemFactory, + systemEntityFactory, userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; @@ -30,7 +30,7 @@ describe('LdapStrategy', () => { let schoolRepoMock: DeepMocked; let authenticationServiceMock: DeepMocked; let ldapServiceMock: DeepMocked; - let systemRepo: DeepMocked; + let systemRepo: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -56,8 +56,8 @@ describe('LdapStrategy', () => { useValue: createMock(), }, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, { provide: Logger, @@ -71,7 +71,7 @@ describe('LdapStrategy', () => { schoolRepoMock = module.get(LegacySchoolRepo); userRepoMock = module.get(UserRepo); ldapServiceMock = module.get(LdapService); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); }); afterAll(async () => { @@ -87,7 +87,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: undefined }); @@ -134,7 +134,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -181,7 +181,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -228,7 +228,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -275,7 +275,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -327,7 +327,7 @@ describe('LdapStrategy', () => { const error = new Error('error'); const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -382,7 +382,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) @@ -445,7 +445,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index 6f33e92f21a..8ffd1aa0bb6 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -2,7 +2,7 @@ import { AccountDto } from '@modules/account/services/dto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo, SystemEntity, User } from '@shared/domain'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; import { Strategy } from 'passport-custom'; @@ -15,7 +15,7 @@ import { LdapService } from '../services/ldap.service'; @Injectable() export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { constructor( - private readonly systemRepo: SystemRepo, + private readonly systemRepo: LegacySystemRepo, private readonly schoolRepo: LegacySchoolRepo, private readonly ldapService: LdapService, private readonly authenticationService: AuthenticationService, diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index f734a72ed8a..ea4f5d178fe 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -13,6 +13,7 @@ import { LessonRule, SchoolExternalToolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, UserLoginMigrationRule, @@ -43,7 +44,8 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; UserRule, UserLoginMigrationRule, LegacySchoolRule, + SystemRule, ], - exports: [FeathersAuthorizationService, AuthorizationService], + exports: [FeathersAuthorizationService, AuthorizationService, SystemRule], }) export class AuthorizationModule {} diff --git a/apps/server/src/modules/authorization/domain/index.ts b/apps/server/src/modules/authorization/domain/index.ts index 0f5cfe67874..ccc52341b5b 100644 --- a/apps/server/src/modules/authorization/domain/index.ts +++ b/apps/server/src/modules/authorization/domain/index.ts @@ -2,3 +2,4 @@ export * from './service'; export * from './mapper'; export * from './error'; export * from './type'; +export { SystemRule } from './rules'; diff --git a/apps/server/src/modules/authorization/domain/rules/group.rule.ts b/apps/server/src/modules/authorization/domain/rules/group.rule.ts index e25e90230c8..9f9b82e15e5 100644 --- a/apps/server/src/modules/authorization/domain/rules/group.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/group.rule.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { User } from '@shared/domain'; +import { User } from '@shared/domain/entity'; import { Group } from '@src/modules/group'; -import { AuthorizationContext, Rule } from '../type'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { AuthorizationContext, Rule } from '../type'; @Injectable() export class GroupRule implements Rule { diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts index b78f43051d0..8a4f1df5109 100644 --- a/apps/server/src/modules/authorization/domain/rules/index.ts +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -15,3 +15,4 @@ export * from './team.rule'; export * from './user-login-migration.rule'; export * from './user.rule'; export * from './group.rule'; +export { SystemRule } from './system.rule'; diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts new file mode 100644 index 00000000000..589e21a03d3 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts @@ -0,0 +1,260 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { System } from '@modules/system'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, SchoolEntity, SystemEntity, User } from '@shared/domain'; +import { schoolFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '../mapper'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { SystemRule } from './system.rule'; + +describe(SystemRule.name, () => { + let module: TestingModule; + let rule: SystemRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + SystemRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(SystemRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const system: System = systemFactory.build(); + + return { + user, + system, + }; + }; + + it('should return true', () => { + const { user, system } = setup(); + + const result = rule.isApplicable(user, system); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should return false', () => { + const { user } = setup(); + + const result = rule.isApplicable(user, {} as unknown as System); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user reads a system at his school and has the required permission', () => { + const setup = () => { + const system: System = systemFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should check the permission', () => { + const { user, system, authorizationContext } = setup(); + + rule.hasPermission(user, system, authorizationContext); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith( + user, + authorizationContext.requiredPermissions + ); + }); + + it('should return true', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(true); + }); + }); + + describe('when the user reads a system, but does not have the permission', () => { + const setup = () => { + const system: System = systemFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(false); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user reads a system that is not at his school', () => { + const setup = () => { + const system: System = systemFactory.build(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user writes a ldap system at his school and has the required permission and the ldap provider is "general"', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: { provider: 'general' } }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return true', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(true); + }); + }); + + describe('when the user writes a ldap system at his school and has the required permission and the ldap provider is not "general"', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: { provider: 'other provider' } }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user writes a non-ldap system at his school and has the required permission', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: undefined }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.ts new file mode 100644 index 00000000000..8e63dae744c --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.ts @@ -0,0 +1,43 @@ +import { System } from '@modules/system'; +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext, Rule } from '../type'; + +@Injectable() +export class SystemRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, domainObject: System): boolean { + const isMatched: boolean = domainObject instanceof System; + + return isMatched; + } + + public hasPermission(user: User, domainObject: System, context: AuthorizationContext): boolean { + const hasPermissions: boolean = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); + + const hasAccess: boolean = user.school.systems.getIdentifiers().includes(domainObject.id); + + let isAuthorized: boolean = hasPermissions && hasAccess; + + if (context.action === Action.write) { + isAuthorized = isAuthorized && this.canEdit(domainObject); + } + + return isAuthorized; + } + + public canEdit(system: unknown): boolean { + const canEdit: boolean = + typeof system === 'object' && + !!system && + 'ldapConfig' in system && + typeof system.ldapConfig === 'object' && + !!system.ldapConfig && + 'provider' in system.ldapConfig && + system.ldapConfig.provider === 'general'; + + return canEdit; + } +} diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 5b62f850416..950c881a9a7 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -8,15 +8,16 @@ import { ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, + LegacySchoolRule, LessonRule, SchoolExternalToolRule, - LegacySchoolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, - UserRule, UserLoginMigrationRule, - GroupRule, + UserRule, } from '../rules'; import { RuleManager } from './rule-manager'; @@ -35,6 +36,7 @@ describe('RuleManager', () => { let contextExternalToolRule: DeepMocked; let userLoginMigrationRule: DeepMocked; let groupRule: DeepMocked; + let systemRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -55,6 +57,7 @@ describe('RuleManager', () => { { provide: BoardDoRule, useValue: createMock() }, { provide: ContextExternalToolRule, useValue: createMock() }, { provide: UserLoginMigrationRule, useValue: createMock() }, + { provide: SystemRule, useValue: createMock() }, ], }).compile(); @@ -72,6 +75,7 @@ describe('RuleManager', () => { contextExternalToolRule = await module.get(ContextExternalToolRule); userLoginMigrationRule = await module.get(UserLoginMigrationRule); groupRule = await module.get(GroupRule); + systemRule = await module.get(SystemRule); }); afterEach(() => { @@ -103,6 +107,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -125,6 +130,7 @@ describe('RuleManager', () => { expect(contextExternalToolRule.isApplicable).toBeCalled(); expect(userLoginMigrationRule.isApplicable).toBeCalled(); expect(groupRule.isApplicable).toBeCalled(); + expect(systemRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -155,6 +161,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -185,6 +192,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index 6e6237d125f..2f35f5e9939 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -1,22 +1,23 @@ import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { BaseDO, User } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; // fix import when it is avaible -import type { AuthorizationContext, Rule } from '../type'; import { BoardDoRule, ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, LegacySchoolRule, LessonRule, SchoolExternalToolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, UserLoginMigrationRule, UserRule, - GroupRule, } from '../rules'; +import type { AuthorizationContext, Rule } from '../type'; @Injectable() export class RuleManager { @@ -35,7 +36,8 @@ export class RuleManager { private readonly boardDoRule: BoardDoRule, private readonly contextExternalToolRule: ContextExternalToolRule, private readonly userLoginMigrationRule: UserLoginMigrationRule, - private readonly groupRule: GroupRule + private readonly groupRule: GroupRule, + private readonly systemRule: SystemRule ) { this.rules = [ this.courseRule, @@ -51,6 +53,7 @@ export class RuleManager { this.contextExternalToolRule, this.userLoginMigrationRule, this.groupRule, + this.systemRule, ]; } diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index 13ae209f13d..b90a586ff54 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -12,6 +12,8 @@ export { AuthorizationService, ForbiddenLoggableException, Rule, + // For the use in feathers + SystemRule, } from './domain'; // Should not used anymore export { FeathersAuthorizationService } from './feathers'; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index ffa1e7ad580..880d9607cc9 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -6,6 +6,8 @@ import { ContentElementFactory } from '@shared/domain'; import { ConsoleWriterModule } from '@infra/console'; 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 { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; import { BoardDoAuthorizableService, @@ -20,7 +22,14 @@ import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './serv import { ColumnBoardCopyService } from './service/column-board-copy.service'; @Module({ - imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule, UserModule, ContextExternalToolModule], + imports: [ + ConsoleWriterModule, + FilesStorageClientModule, + LoggerModule, + UserModule, + ContextExternalToolModule, + HttpModule, + ], providers: [ BoardDoAuthorizableService, BoardDoRepo, @@ -37,6 +46,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + DrawingElementAdapterService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index 84681de7691..ec3f1beb96c 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,4 +1,5 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; +import { DrawingElementResponse } from './drawing-element.response'; import { FileElementResponse } from './file-element.response'; import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; @@ -9,7 +10,8 @@ export type AnyContentElementResponse = | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse - | ExternalToolElementResponse; + | ExternalToolElementResponse + | DrawingElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts new file mode 100644 index 00000000000..7c2b0e20852 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class DrawingElementContent { + constructor({ description }: DrawingElementContent) { + this.description = description; + } + + @ApiProperty() + description: string; +} + +export class DrawingElementResponse { + constructor({ id, content, timestamps, type }: DrawingElementResponse) { + this.id = id; + this.timestamps = timestamps; + this.type = type; + this.content = content; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.DRAWING; + + @ApiProperty() + timestamps: TimestampsResponse; + + @ApiProperty() + content: DrawingElementContent; +} diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 6787c007c1b..3b85cb57f3f 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,5 +1,6 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; +export * from './drawing-element.response'; export * from './external-tool-element.response'; export * from './file-element.response'; export * from './link-element.response'; diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 7d0314208c6..f6fbfd11043 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -73,6 +73,12 @@ export class RichTextContentBody { inputFormat!: InputFormat; } +export class DrawingContentBody { + @IsString() + @ApiProperty() + description!: string; +} + export class RichTextElementContentBody extends ElementContentBody { @ApiProperty({ type: ContentElementType.RICH_TEXT }) type!: ContentElementType.RICH_TEXT; @@ -118,6 +124,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | DrawingContentBody | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index 2b61e273185..c4fb577f5c2 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,6 +1,7 @@ import { NotImplementedException } from '@nestjs/common'; import { fileElementFactory, + drawingElementFactory, linkElementFactory, richTextElementFactory, submissionContainerElementFactory, @@ -8,6 +9,7 @@ import { import { FileElementResponse, LinkElementResponse, + DrawingElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, } from '../dto'; @@ -37,6 +39,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(RichTextElementResponse); }); + it('should return instance of DrawingElementResponse', () => { + const drawingElement = drawingElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(drawingElement); + + expect(result).toBeInstanceOf(DrawingElementResponse); + }); + it('should return instance of SubmissionContainerElementResponse', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 8431b630be9..72311882bb8 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -8,6 +8,7 @@ import { isRichTextElementResponse, } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; +import { DrawingElementResponseMapper } from './drawing-element-response.mapper'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; import { LinkElementResponseMapper } from './link-element-response.mapper'; @@ -19,6 +20,7 @@ export class ContentElementResponseFactory { FileElementResponseMapper.getInstance(), LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), + DrawingElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), ]; diff --git a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts new file mode 100644 index 00000000000..1ab07081f07 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts @@ -0,0 +1,32 @@ +import { ContentElementType } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementContent, DrawingElementResponse } from '../dto/element/drawing-element.response'; +import { TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class DrawingElementResponseMapper implements BaseResponseMapper { + private static instance: DrawingElementResponseMapper; + + public static getInstance(): DrawingElementResponseMapper { + if (!DrawingElementResponseMapper.instance) { + DrawingElementResponseMapper.instance = new DrawingElementResponseMapper(); + } + + return DrawingElementResponseMapper.instance; + } + + mapToResponse(element: DrawingElement): DrawingElementResponse { + const result = new DrawingElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.DRAWING, + content: new DrawingElementContent({ description: element.description }), + }); + + return result; + } + + canMap(element: DrawingElement): boolean { + return element instanceof DrawingElement; + } +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index 8bbc859fa17..43bdc325623 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -10,6 +10,7 @@ import { setupEntities, submissionContainerElementNodeFactory, } from '@shared/testing'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; import { BoardDoBuilderImpl } from './board-do.builder-impl'; describe(BoardDoBuilderImpl.name, () => { @@ -168,6 +169,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a drawing element', () => { + it('should work without descendants', () => { + const drawingElementNode = drawingElementNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildDrawingElement(drawingElementNode); + + expect(domainObject.constructor.name).toBe('DrawingElement'); + }); + + it('should throw error if drawingElement is not a leaf', () => { + const drawingElementNode = drawingElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: drawingElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildDrawingElement(drawingElementNode); + }).toThrowError(); + }); + }); + describe('when building a submission container element', () => { it('should work without descendants', () => { const submissionContainerElementNode = submissionContainerElementNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 6e2b375991e..2154264fd20 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -25,6 +25,8 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; export class BoardDoBuilderImpl implements BoardDoBuilder { private childrenMap: Record = {}; @@ -77,6 +79,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { BoardNodeType.FILE_ELEMENT, BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, + BoardNodeType.DRAWING_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); @@ -139,6 +142,19 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildDrawingElement(boardNode: DrawingElementNode): DrawingElement { + this.ensureLeafNode(boardNode); + + const element = new DrawingElement({ + id: boardNode.id, + description: boardNode.description, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement { this.ensureBoardNodeType(this.getChildren(boardNode), [BoardNodeType.SUBMISSION_ITEM]); const elements = this.buildChildren(boardNode); diff --git a/apps/server/src/modules/board/repo/board-do.repo.spec.ts b/apps/server/src/modules/board/repo/board-do.repo.spec.ts index 2f9a6633e69..2bf3037973d 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.spec.ts @@ -28,6 +28,7 @@ import { richTextElementFactory, richTextElementNodeFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { BoardDoRepo } from './board-do.repo'; import { BoardNodeRepo } from './board-node.repo'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; @@ -48,6 +49,7 @@ describe(BoardDoRepo.name, () => { RecursiveDeleteVisitor, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); repo = module.get(BoardDoRepo); diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 544d492fe65..49604d6fcbf 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -8,6 +8,7 @@ import { columnBoardFactory, columnFactory, contextExternalToolFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -15,6 +16,7 @@ import { submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; describe(RecursiveDeleteVisitor.name, () => { @@ -24,6 +26,7 @@ describe(RecursiveDeleteVisitor.name, () => { let em: DeepMocked; let filesStorageClientAdapterService: DeepMocked; let contextExternalToolService: DeepMocked; + let drawingElementAdapterService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -32,6 +35,7 @@ describe(RecursiveDeleteVisitor.name, () => { { provide: EntityManager, useValue: createMock() }, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); @@ -39,6 +43,7 @@ describe(RecursiveDeleteVisitor.name, () => { em = module.get(EntityManager); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); contextExternalToolService = module.get(ContextExternalToolService); + drawingElementAdapterService = module.get(DrawingElementAdapterService); await setupEntities(); }); @@ -181,6 +186,30 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitDrawingElementAsync', () => { + const setup = () => { + const childDrawingElement = drawingElementFactory.build(); + + return { childDrawingElement }; + }; + + it('should call entity remove', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(childDrawingElement.constructor, childDrawingElement.id)); + }); + + it('should trigger deletion of tldraw data via adapter', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(drawingElementAdapterService.deleteDrawingBinData).toHaveBeenCalledWith(childDrawingElement.id); + }); + }); + describe('visitSubmissionContainerElementAsync', () => { const setup = () => { const childSubmissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 6c8301f6b6f..a4ba34425e0 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -17,13 +17,16 @@ import { SubmissionItem, } from '@shared/domain'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; @Injectable() export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { constructor( private readonly em: EntityManager, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly contextExternalToolService: ContextExternalToolService + private readonly contextExternalToolService: ContextExternalToolService, + private readonly drawingElementAdapterService: DrawingElementAdapterService ) {} async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { @@ -60,6 +63,13 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + await this.drawingElementAdapterService.deleteDrawingBinData(drawingElement.id); + + this.deleteNode(drawingElement); + await this.visitChildrenAsync(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { this.deleteNode(submissionContainerElement); await this.visitChildrenAsync(submissionContainerElement); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 3fd95c18525..55be7d41d75 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -9,6 +9,7 @@ import { FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from '@shared/domain'; @@ -23,6 +24,7 @@ import { linkElementFactory, richTextElementFactory, setupEntities, + drawingElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; @@ -120,6 +122,16 @@ describe(RecursiveSaveVisitor.name, () => { expect(richTextElement.accept).toHaveBeenCalledWith(visitor); }); + + it('should visit the children (drawing)', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(drawingElement, 'accept'); + const card = cardFactory.build({ children: [drawingElement] }); + + card.accept(visitor); + + expect(drawingElement.accept).toHaveBeenCalledWith(visitor); + }); }); describe('when visiting a file element composite', () => { @@ -171,6 +183,21 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a drawing element composite', () => { + it('should create or update the node', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitDrawingElement(drawingElement); + + const expectedNode: Partial = { + id: drawingElement.id, + type: BoardNodeType.DRAWING_ELEMENT, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a submission container element composite', () => { it('should create or update the node', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index e379cd5c788..32abcfb0747 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -25,6 +25,8 @@ import { import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; import { BoardNodeRepo } from './board-node.repo'; type ParentData = { @@ -136,6 +138,19 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.saveRecursive(boardNode, richTextElement); } + visitDrawingElement(drawingElement: DrawingElement): void { + const parentData = this.parentsMap.get(drawingElement.id); + + const boardNode = new DrawingElementNode({ + id: drawingElement.id, + description: drawingElement.description ?? '', + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.saveRecursive(boardNode, drawingElement); + } + visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { const parentData = this.parentsMap.get(submissionContainerElement.id); 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 40a62ede2ed..04a3e8dce3c 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 @@ -4,11 +4,13 @@ import { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, isCard, isColumn, isColumnBoard, + isDrawingElement, isExternalToolElement, isFileElement, isLinkElement, @@ -23,6 +25,7 @@ import { cardFactory, columnBoardFactory, columnFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -437,6 +440,62 @@ describe('recursive board copy visitor', () => { }); }); + describe('when copying a drawing element', () => { + const setup = () => { + const original = drawingElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getDrawingElementFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isDrawingElement(copy)).toEqual(true); + return copy as DrawingElement; + }; + + it('should return a drawing element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isDrawingElement(result.copyEntity)).toEqual(true); + }); + + it('should copy description', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(result); + + expect(copy.description).toEqual(original.description); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(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 RichTextElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.DRAWING_ELEMENT); + }); + }); + describe('when copying a file element', () => { const setup = () => { const original = fileElementFactory.build(); 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 03cdfb15b69..a70d133d324 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 @@ -6,6 +6,7 @@ import { Card, Column, ColumnBoard, + DrawingElement, EntityId, ExternalToolElement, FileElement, @@ -123,6 +124,24 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitDrawingElementAsync(original: DrawingElement): Promise { + const copy = new DrawingElement({ + id: new ObjectId().toHexString(), + description: original.description, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.DRAWING_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitLinkElementAsync(original: LinkElement): Promise { const copy = new LinkElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index 0b55dfdb1de..6061c05eb42 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -5,6 +5,7 @@ import { columnBoardFactory, columnFactory, externalToolElementFactory, + drawingElementFactory, fileElementFactory, linkElementFactory, richTextElementFactory, @@ -93,6 +94,23 @@ describe(ContentElementUpdateVisitor.name, () => { }); }); + describe('when visiting a drawing element using the wrong content', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const updater = new ContentElementUpdateVisitor(content); + + return { drawingElement, updater }; + }; + + it('should throw an error', async () => { + const { drawingElement, updater } = setup(); + + await expect(() => updater.visitDrawingElementAsync(drawingElement)).rejects.toThrow(); + }); + }); + describe('when visiting a submission container element using the wrong content', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index 86e3fb67985..77d9581dec4 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -13,9 +13,11 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, + DrawingContentBody, ExternalToolContentBody, FileContentBody, LinkContentBody, @@ -82,6 +84,14 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { return this.rejectNotHandled(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + if (this.content instanceof DrawingContentBody) { + drawingElement.description = this.content.description; + return Promise.resolve(); + } + return this.rejectNotHandled(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { if (this.content.dueDate !== undefined) { diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index d70fe591bbb..73bbf150f80 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ContentElementFactory, @@ -9,7 +9,7 @@ import { RichTextElement, SubmissionContainerElement, } from '@shared/domain'; -import { setupEntities } from '@shared/testing'; +import { drawingElementFactory, setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, @@ -18,6 +18,7 @@ import { submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; import { + DrawingContentBody, FileContentBody, LinkContentBody, RichTextContentBody, @@ -190,6 +191,25 @@ describe(ContentElementService.name, () => { expect(boardDoRepo.save).toHaveBeenCalledWith([richTextElement], card); }); }); + + describe('when creating a drawing element multiple times', () => { + const setup = () => { + const card = cardFactory.build(); + const drawingElement = drawingElementFactory.build(); + + contentElementFactory.build.mockReturnValue(drawingElement); + + return { card, drawingElement }; + }; + + it('should return error for second creation', async () => { + const { card } = setup(); + + await service.create(card, ContentElementType.DRAWING); + + await expect(service.create(card, ContentElementType.DRAWING)).rejects.toThrow(BadRequestException); + }); + }); }); describe('delete', () => { @@ -248,6 +268,34 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a drawing element', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new DrawingContentBody(); + content.description = 'test-description'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + return { drawingElement, content, card }; + }; + + it('should update the element', async () => { + const { drawingElement, content } = setup(); + + await service.update(drawingElement, content); + + expect(drawingElement.description).toEqual(content.description); + }); + + it('should persist the element', async () => { + const { drawingElement, content, card } = setup(); + + await service.update(drawingElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(drawingElement, card); + }); + }); + describe('when element is a file element', () => { const setup = () => { const fileElement = fileElementFactory.build(); diff --git a/apps/server/src/modules/board/uc/card.uc.spec.ts b/apps/server/src/modules/board/uc/card.uc.spec.ts index 7df87747b9c..004944f6dd5 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -6,6 +6,7 @@ import { cardFactory, richTextElementFactory } from '@shared/testing/factory/dom import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@modules/authorization'; import { ObjectId } from 'bson'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, CardService } from '../service'; import { CardUc } from './card.uc'; @@ -41,6 +42,10 @@ describe(CardUc.name, () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); 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 e17c20bb067..e28fb7638ec 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardDoAuthorizable, InputFormat } from '@shared/domain'; import { fileElementFactory, + drawingElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -13,6 +14,7 @@ import { Logger } from '@src/core/logger'; import { AuthorizationService, Action } from '@modules/authorization'; import { ObjectId } from 'bson'; import { ForbiddenException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; import { ElementUc } from './element.uc'; @@ -47,6 +49,10 @@ describe(ElementUc.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); @@ -179,16 +185,18 @@ describe(ElementUc.name, () => { expect(elementService.delete).toHaveBeenCalledWith(element); }); }); + describe('when deleting a content element', () => { const setup = () => { const user = userFactory.build(); const element = richTextElementFactory.build(); + const drawingElement = drawingElementFactory.build(); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) ); - return { user, element }; + return { user, element, drawingElement }; }; it('should call the service to find the element', async () => { 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 fff1f0da795..b1968ec4dee 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -22,6 +22,7 @@ export enum CopyElementType { 'EXTERNAL_TOOL_ELEMENT' = 'EXTERNAL_TOOL_ELEMENT', 'FILE' = 'FILE', 'FILE_ELEMENT' = 'FILE_ELEMENT', + 'DRAWING_ELEMENT' = 'DRAWING_ELEMENT', 'FILE_GROUP' = 'FILE_GROUP', 'LEAF' = 'LEAF', 'LESSON' = 'LESSON', diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts index 096b1f9b082..478f23a2348 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.spec.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AxiosResponse } from 'axios'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -51,6 +51,23 @@ describe(DeletionClient.name, () => { }); describe('queueDeletionRequest', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { input }; + }; + + it('should catch and throw an error', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + describe('when received valid response with expected HTTP status code', () => { const setup = () => { const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); @@ -151,4 +168,55 @@ describe(DeletionClient.name, () => { }); }); }); + + describe('executeDeletions', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + }; + + it('should catch and throw an error', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const limit = 10; + + const response: AxiosResponse = axiosResponseFactory.build({ + status: 204, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { limit }; + }; + + it('should return proper output', async () => { + const { limit } = setup(); + + await expect(client.executeDeletions(limit)).resolves.not.toThrow(); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + }; + + it('should throw an exception', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + }); }); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts index 66bb267d070..a3c47844656 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -1,9 +1,9 @@ -import { firstValueFrom } from 'rxjs'; -import { AxiosResponse } from 'axios'; -import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; +import { BadGatewayException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; +import { ErrorUtils } from '@src/core/error/utils'; +import { firstValueFrom } from 'rxjs'; +import { DeletionClientConfig, DeletionRequestInput, DeletionRequestOutput } from './interface'; @Injectable() export class DeletionClient { @@ -13,6 +13,8 @@ export class DeletionClient { private readonly postDeletionRequestsEndpoint: string; + private readonly postDeletionExecutionsEndpoint: string; + constructor( private readonly httpService: HttpService, private readonly configService: ConfigService @@ -22,36 +24,65 @@ export class DeletionClient { // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + this.postDeletionExecutionsEndpoint = new URL('/admin/api/v1/deletionExecutions', this.baseUrl).toString(); } async queueDeletionRequest(input: DeletionRequestInput): Promise { - const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); - - return firstValueFrom(request) - .then((resp: AxiosResponse) => { - // Throw an error if any other status code (other than expected "202 Accepted" is returned). - if (resp.status !== 202) { - throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); - } - - // Throw an error if server didn't return a requestId in a response (and it is - // required as it gives client the reference to the created deletion request). - if (!resp.data.requestId) { - throw new Error('no valid requestId returned from the server'); - } - - // Throw an error if server didn't return a deletionPlannedAt timestamp so the user - // will not be aware after which date the deletion request's execution will begin. - if (!resp.data.deletionPlannedAt) { - throw new Error('no valid deletionPlannedAt returned from the server'); - } - - return resp.data; - }) - .catch((err: Error) => { - // Throw an error if sending/processing deletion request by the client failed in any way. - throw new Error(`failed to send/process a deletion request: ${err.toString()}`); - }); + try { + const request = this.httpService.post( + this.postDeletionRequestsEndpoint, + input, + this.defaultHeaders() + ); + + const resp = await firstValueFrom(request); + + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + } catch (err) { + // Throw an error if sending deletion request has failed. + throw new BadGatewayException('DeletionClient:queueDeletionRequest', ErrorUtils.createHttpExceptionOptions(err)); + } + } + + async executeDeletions(limit?: number): Promise { + let requestConfig = {}; + + if (limit && limit > 0) { + requestConfig = { ...this.defaultHeaders(), params: { limit } }; + } else { + requestConfig = { ...this.defaultHeaders() }; + } + + try { + const request = this.httpService.post(this.postDeletionExecutionsEndpoint, null, requestConfig); + + const resp = await firstValueFrom(request); + + if (resp.status !== 204) { + // Throw an error if any other status code (other than expected "204 No Content" is returned). + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 204`); + } + } catch (err) { + // Throw an error if sending deletion request(s) execution trigger has failed. + throw new BadGatewayException('DeletionClient:executeDeletions', ErrorUtils.createHttpExceptionOptions(err)); + } } private apiKeyHeader() { diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts new file mode 100644 index 00000000000..b0217e6a2c2 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts @@ -0,0 +1,45 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; +import { DeletionExecutionTriggerResultBuilder } from './deletion-execution-trigger-result.builder'; + +describe(DeletionExecutionTriggerResultBuilder.name, () => { + describe(DeletionExecutionTriggerResultBuilder.buildSuccess.name, () => { + describe('when called', () => { + const setup = () => { + const expectedOutput: DeletionExecutionTriggerResult = { status: DeletionExecutionTriggerStatus.SUCCESS }; + + return { expectedOutput }; + }; + + it('should return valid object indicating success', () => { + const { expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildSuccess(); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(DeletionExecutionTriggerResultBuilder.buildFailure.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: DeletionExecutionTriggerResult = { + status: DeletionExecutionTriggerStatus.FAILURE, + error: error.toString(), + }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildFailure(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts new file mode 100644 index 00000000000..e660e6905ea --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts @@ -0,0 +1,21 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; + +export class DeletionExecutionTriggerResultBuilder { + private static build(status: DeletionExecutionTriggerStatus, error?: string): DeletionExecutionTriggerResult { + const output: DeletionExecutionTriggerResult = { status }; + + if (error) { + output.error = error; + } + + return output; + } + + static buildSuccess(): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.SUCCESS); + } + + static buildFailure(err: Error): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.FAILURE, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts index 12fd0997ebe..985edf66371 100644 --- a/apps/server/src/modules/deletion/console/builder/index.ts +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -1 +1,3 @@ export * from './push-delete-requests-options.builder'; +export * from './trigger-deletion-execution-options.builder'; +export * from './deletion-execution-trigger-result.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts new file mode 100644 index 00000000000..21171adb405 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts @@ -0,0 +1,24 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; +import { TriggerDeletionExecutionOptionsBuilder } from './trigger-deletion-execution-options.builder'; + +describe(TriggerDeletionExecutionOptionsBuilder.name, () => { + describe(TriggerDeletionExecutionOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const limit = 1000; + + const expectedOutput: TriggerDeletionExecutionOptions = { limit }; + + return { limit, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { limit, expectedOutput } = setup(); + + const output = TriggerDeletionExecutionOptionsBuilder.build(limit); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts new file mode 100644 index 00000000000..ed652006e9d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts @@ -0,0 +1,7 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; + +export class TriggerDeletionExecutionOptionsBuilder { + static build(limit: number): TriggerDeletionExecutionOptions { + return { limit }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts index 0585b3631da..504c1c35885 100644 --- a/apps/server/src/modules/deletion/console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -7,8 +7,9 @@ import { createConfigModuleOptions } from '@src/config'; import { DeletionClient } from '../client'; import { getDeletionClientConfig } from '../client/deletion-client.config'; import { BatchDeletionService } from '../services'; -import { BatchDeletionUc } from '../uc'; +import { BatchDeletionUc, DeletionExecutionUc } from '../uc'; import { DeletionQueueConsole } from './deletion-queue.console'; +import { DeletionExecutionConsole } from './deletion-execution.console'; @Module({ imports: [ @@ -17,6 +18,13 @@ import { DeletionQueueConsole } from './deletion-queue.console'; HttpModule, ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), ], - providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], + providers: [ + DeletionClient, + BatchDeletionService, + BatchDeletionUc, + DeletionExecutionUc, + DeletionQueueConsole, + DeletionExecutionConsole, + ], }) export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts new file mode 100644 index 00000000000..39519bab6a9 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionConsole } from './deletion-execution.console'; +import { DeletionExecutionTriggerResultBuilder, TriggerDeletionExecutionOptionsBuilder } from './builder'; + +describe(DeletionExecutionConsole.name, () => { + let module: TestingModule; + let console: DeletionExecutionConsole; + let deletionExecutionUc: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: DeletionExecutionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionExecutionConsole); + deletionExecutionUc = module.get(DeletionExecutionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe('when called with valid options', () => { + const setup = () => { + const limit = 1000; + + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + return { limit, options }; + }; + + it(`should call ${DeletionExecutionUc.name} with proper arguments`, async () => { + const { limit, options } = setup(); + + const spy = jest.spyOn(deletionExecutionUc, 'triggerDeletionExecution'); + + await console.triggerDeletionExecution(options); + + expect(spy).toBeCalledWith(limit); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method doesn't throw an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + deletionExecutionUc.triggerDeletionExecution.mockResolvedValueOnce(undefined); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildSuccess'); + + return { options, spy }; + }; + + it('should prepare result indicating success', async () => { + const { options, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method throws an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + const err = new Error('some error occurred...'); + + deletionExecutionUc.triggerDeletionExecution.mockRejectedValueOnce(err); + + // const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildFailure'); + + return { options, err, spy }; + }; + + it('should prepare result indicating failure', async () => { + const { options, err, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalledWith(err); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.ts new file mode 100644 index 00000000000..d9fdabe0160 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.ts @@ -0,0 +1,38 @@ +import { Command, Console } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionTriggerResultBuilder } from './builder'; +import { DeletionExecutionTriggerResult, TriggerDeletionExecutionOptions } from './interface'; + +@Console({ command: 'execution', description: 'Console providing an access to the deletion execution(s).' }) +export class DeletionExecutionConsole { + constructor(private consoleWriter: ConsoleWriterService, private deletionExecutionUc: DeletionExecutionUc) {} + + @Command({ + command: 'trigger', + description: 'Trigger execution of deletion requests.', + options: [ + { + flags: '-l, --limit ', + description: 'Limit of the requested deletion executions that should be performed.', + required: false, + }, + ], + }) + async triggerDeletionExecution(options: TriggerDeletionExecutionOptions): Promise { + // Try to trigger the deletion execution(s) via Deletion API client, + // return successful status in case of a success, otherwise return + // a result with a failure status and a proper error message. + let result: DeletionExecutionTriggerResult; + + try { + await this.deletionExecutionUc.triggerDeletionExecution(options.limit ? Number(options.limit) : undefined); + + result = DeletionExecutionTriggerResultBuilder.buildSuccess(); + } catch (err) { + result = DeletionExecutionTriggerResultBuilder.buildFailure(err as Error); + } + + this.consoleWriter.info(JSON.stringify(result)); + } +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts new file mode 100644 index 00000000000..787100ec048 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts @@ -0,0 +1,6 @@ +import { DeletionExecutionTriggerStatus } from './deletion-execution-trigger-status.enum'; + +export interface DeletionExecutionTriggerResult { + status: DeletionExecutionTriggerStatus; + error?: string; +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts new file mode 100644 index 00000000000..2b241cf72fc --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionExecutionTriggerStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts index 2fcb281430f..b15a668b53e 100644 --- a/apps/server/src/modules/deletion/console/interface/index.ts +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -1 +1,4 @@ export * from './push-delete-requests-options.interface'; +export * from './trigger-deletion-execution-options.interface'; +export * from './deletion-execution-trigger-status.enum'; +export * from './deletion-execution-trigger-result'; diff --git a/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts new file mode 100644 index 00000000000..b17aafa1112 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts @@ -0,0 +1,3 @@ +export interface TriggerDeletionExecutionOptions { + limit: number; +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts index 1a4f3bcf425..daa4985498d 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -6,6 +6,7 @@ export const enum DeletionDomainModel { FILE = 'file', LESSONS = 'lessons', PSEUDONYMS = 'pseudonyms', + REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', TEAMS = 'teams', USER = 'user', diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts new file mode 100644 index 00000000000..39c8065645a --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient } from '../client'; +import { DeletionExecutionUc } from './deletion-execution.uc'; + +describe(DeletionExecutionUc.name, () => { + let module: TestingModule; + let uc: DeletionExecutionUc; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionUc, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionExecutionUc); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const limit = 1000; + + deletionClient.executeDeletions.mockResolvedValueOnce(undefined); + + return { limit }; + }; + + it('should also not throw an error', async () => { + const { limit } = setup(); + + await expect(uc.triggerDeletionExecution(limit)).resolves.not.toThrow(); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const error = new Error('connection error'); + + deletionClient.executeDeletions.mockRejectedValueOnce(error); + }; + + it('should also throw an error', async () => { + setup(); + + await expect(uc.triggerDeletionExecution()).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts new file mode 100644 index 00000000000..ad4c90c567d --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { DeletionClient } from '../client'; + +@Injectable() +export class DeletionExecutionUc { + constructor(private readonly deletionClient: DeletionClient) {} + + async triggerDeletionExecution(limit?: number): Promise { + await this.deletionClient.executeDeletions(limit); + } +} 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 34c34e302f5..69ec72a0db5 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 @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { setupEntities } from '@shared/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; import { AccountService } from '@modules/account/services'; import { ClassService } from '@modules/class'; import { CourseGroupService, CourseService } from '@modules/learnroom/service'; @@ -12,6 +12,7 @@ import { UserService } from '@modules/user'; import { RocketChatService } from '@modules/rocketchat'; import { rocketChatUserFactory } from '@modules/rocketchat-user/domain/testing'; import { RocketChatUser, RocketChatUserService } from '@modules/rocketchat-user'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; @@ -37,6 +38,7 @@ describe(DeletionRequestUc.name, () => { let userService: DeepMocked; let rocketChatUserService: DeepMocked; let rocketChatService: DeepMocked; + let registrationPinService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -94,6 +96,10 @@ describe(DeletionRequestUc.name, () => { provide: RocketChatService, useValue: createMock(), }, + { + provide: RegistrationPinService, + useValue: createMock(), + }, ], }).compile(); @@ -111,6 +117,7 @@ describe(DeletionRequestUc.name, () => { userService = module.get(UserService); rocketChatUserService = module.get(RocketChatUserService); rocketChatService = module.get(RocketChatService); + registrationPinService = module.get(RegistrationPinService); await setupEntities(); }); @@ -168,10 +175,13 @@ describe(DeletionRequestUc.name, () => { const setup = () => { jest.clearAllMocks(); const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + const user = userDoFactory.buildWithId(); const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ userId: deletionRequestToExecute.targetRefId, }); + const parentEmail = 'parent@parent.eu'; + registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); @@ -186,6 +196,8 @@ describe(DeletionRequestUc.name, () => { return { deletionRequestToExecute, rocketChatUser, + user, + parentEmail, }; }; @@ -215,6 +227,29 @@ describe(DeletionRequestUc.name, () => { expect(accountService.deleteByUserId).toHaveBeenCalled(); }); + it('should call registrationPinService.deleteRegistrationPinByEmail to delete user data in registrationPin module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(registrationPinService.deleteRegistrationPinByEmail).toHaveBeenCalled(); + }); + + it('should call userService.getParentEmailsFromUser to get parentEmails', async () => { + const { deletionRequestToExecute, user, parentEmail } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + userService.findById.mockResolvedValueOnce(user); + userService.getParentEmailsFromUser.mockRejectedValue([parentEmail]); + registrationPinService.deleteRegistrationPinByEmail.mockRejectedValueOnce(2); + + await uc.executeDeletionRequests(); + + expect(userService.getParentEmailsFromUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { const { deletionRequestToExecute } = setup(); 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 abea56fda96..7bacc428310 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -10,6 +10,7 @@ import { FilesService } from '@modules/files/service'; import { AccountService } from '@modules/account/services'; import { RocketChatUserService } from '@modules/rocketchat-user'; import { RocketChatService } from '@modules/rocketchat'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionRequestService } from '../services/deletion-request.service'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; @@ -42,7 +43,8 @@ export class DeletionRequestUc { private readonly teamService: TeamService, private readonly userService: UserService, private readonly rocketChatUserService: RocketChatUserService, - private readonly rocketChatService: RocketChatService + private readonly rocketChatService: RocketChatService, + private readonly registrationPinService: RegistrationPinService ) {} async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { @@ -101,6 +103,7 @@ export class DeletionRequestUc { this.removeUserFromTeams(deletionRequest), this.removeUser(deletionRequest), this.removeUserFromRocketChat(deletionRequest), + this.removeUserRegistrationPin(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -131,6 +134,25 @@ export class DeletionRequestUc { await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); } + private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { + const userToDeletion = await this.userService.findById(deletionRequest.targetRefId); + const parentEmails = await this.userService.getParentEmailsFromUser(deletionRequest.targetRefId); + const emailsToDeletion: string[] = [userToDeletion.email, ...parentEmails]; + + const result = await Promise.all( + emailsToDeletion.map((email) => this.registrationPinService.deleteRegistrationPinByEmail(email)) + ); + const deletedRegistrationPin = result.filter((res) => res !== 0).length; + + await this.logDeletion( + deletionRequest, + DeletionDomainModel.REGISTRATIONPIN, + DeletionOperationModel.DELETE, + 0, + deletedRegistrationPin + ); + } + private async removeUserFromClasses(deletionRequest: DeletionRequest) { const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); await this.logDeletion( diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts index cf74de969e5..4b1451b563d 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -1,2 +1,3 @@ export * from './interface'; export * from './batch-deletion.uc'; +export * from './deletion-execution.uc'; 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 dc1753a6c7f..f5ab532f9b7 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 @@ -10,7 +10,7 @@ import { roleFactory, schoolFactory, schoolYearFactory, - systemFactory, + systemEntityFactory, TestApiClient, UserAndAccountTestFactory, userFactory, @@ -51,7 +51,7 @@ describe('Group (API)', () => { const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', 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 51cf6151d45..10cfc03821f 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -7,7 +7,7 @@ import { classFactory } from '@modules/class/domain/testing/factory/class.factor import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -23,13 +23,13 @@ import { userDoFactory, userFactory, } from '@shared/testing'; +import { SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto, ResolvedGroupDto } from './dto'; import { ClassRootType } from './dto/class-root-type'; import { GroupUc } from './group.uc'; -import { SchoolYearQueryType } from '../controller/dto/interface'; -import { UnknownQueryTypeLoggableException } from '../loggable'; describe('GroupUc', () => { let module: TestingModule; @@ -37,7 +37,7 @@ describe('GroupUc', () => { let groupService: DeepMocked; let classService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let userService: DeepMocked; let roleService: DeepMocked; let schoolService: DeepMocked; @@ -57,8 +57,8 @@ describe('GroupUc', () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: UserService, @@ -86,7 +86,7 @@ describe('GroupUc', () => { uc = module.get(GroupUc); groupService = module.get(GroupService); classService = module.get(ClassService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); userService = module.get(UserService); roleService = module.get(RoleService); schoolService = module.get(LegacySchoolService); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index f7399fa2fc9..2de4d9d5a2c 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -4,24 +4,24 @@ import { Class } from '@modules/class/domain'; import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { EntityId, LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; +import { SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupUser } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { SortHelper } from '../util'; import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; -import { SchoolYearQueryType } from '../controller/dto/interface'; -import { UnknownQueryTypeLoggableException } from '../loggable'; @Injectable() export class GroupUc { constructor( private readonly groupService: GroupService, private readonly classService: ClassService, - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly userService: UserService, private readonly roleService: RoleService, private readonly schoolService: LegacySchoolService, diff --git a/apps/server/src/modules/management/seed-data/systems.ts b/apps/server/src/modules/management/seed-data/systems.ts index a9140458f6d..7aca67d896d 100644 --- a/apps/server/src/modules/management/seed-data/systems.ts +++ b/apps/server/src/modules/management/seed-data/systems.ts @@ -1,10 +1,10 @@ /* eslint-disable no-template-curly-in-string */ -import { SystemProperties } from '@shared/domain'; +import { SystemEntityProps } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { DeepPartial } from 'fishery'; -type SystemPartial = DeepPartial & { +type SystemPartial = DeepPartial & { id?: string; createdAt?: string; updatedAt?: string; @@ -66,7 +66,7 @@ const data: SystemPartial[] = [ export function generateSystems(injectEnvVars: (s: string) => string) { const systems = data.map((d) => { d = JSON.parse(injectEnvVars(JSON.stringify(d))) as typeof d; - const params: DeepPartial = { + const params: DeepPartial = { alias: d.alias, displayName: d.displayName, type: d.type, @@ -77,7 +77,7 @@ export function generateSystems(injectEnvVars: (s: string) => string) { provisioningUrl: d.provisioningUrl, url: d.url, }; - const system = systemFactory.buildWithId(params, d.id); + const system = systemEntityFactory.buildWithId(params, d.id); if (d.createdAt) system.createdAt = new Date(d.createdAt); if (d.updatedAt) system.updatedAt = new Date(d.updatedAt); diff --git a/apps/server/src/modules/oauth/service/hydra.service.spec.ts b/apps/server/src/modules/oauth/service/hydra.service.spec.ts index 2dc2a22a6ce..37db961eb9d 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.spec.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.spec.ts @@ -1,18 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; +import { CookiesDto } from '@modules/oauth/service/dto/cookies.dto'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; +import { HydraSsoService } from '@modules/oauth/service/hydra.service'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LtiPrivacyPermission, LtiRoleType, OauthConfig } from '@shared/domain'; +import { LtiPrivacyPermission, LtiRoleType, OauthConfigEntity } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { LtiToolRepo } from '@shared/repo'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { CookiesDto } from '@modules/oauth/service/dto/cookies.dto'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; -import { HydraSsoService } from '@modules/oauth/service/hydra.service'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; @@ -47,7 +47,7 @@ describe('HydraService', () => { const scopes = 'openid uuid'; const apiHost = 'localhost'; - const oauthConfig: OauthConfig = new OauthConfig({ + const oauthConfig: OauthConfigEntity = new OauthConfigEntity({ clientId: '12345', clientSecret: 'mocksecret', tokenEndpoint: `${hydraUri}/oauth2/token`, @@ -242,7 +242,7 @@ describe('HydraService', () => { ltiToolRepo.findByOauthClientId.mockResolvedValue(ltiToolDoMock); // Act - const result: OauthConfig = await service.generateConfig(oauthConfig.clientId); + const result: OauthConfigEntity = await service.generateConfig(oauthConfig.clientId); // Assert expect(result).toEqual(oauthConfig); diff --git a/apps/server/src/modules/oauth/service/hydra.service.ts b/apps/server/src/modules/oauth/service/hydra.service.ts index 9c02a537d42..a74e1936af8 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.ts @@ -6,7 +6,7 @@ import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto' import { HttpService } from '@nestjs/axios'; import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { LtiToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; @@ -26,7 +26,7 @@ export class HydraSsoService { private readonly HOST: string = Configuration.get('HOST') as string; - async initAuth(oauthConfig: OauthConfig, axiosConfig: AxiosRequestConfig): Promise { + async initAuth(oauthConfig: OauthConfigEntity, axiosConfig: AxiosRequestConfig): Promise { const query = QueryString.stringify({ response_type: oauthConfig.responseType, scope: oauthConfig.scope, @@ -96,7 +96,7 @@ export class HydraSsoService { return cookiesDto; } - async generateConfig(oauthClientId: string): Promise { + async generateConfig(oauthClientId: string): Promise { const tool: LtiToolDO = await this.ltiRepo.findByOauthClientId(oauthClientId); // Needs to be checked, because the fields can be undefined @@ -105,7 +105,7 @@ export class HydraSsoService { } const hydraUri: string = Configuration.get('HYDRA_PUBLIC_URI') as string; - const hydraOauthConfig = new OauthConfig({ + const hydraOauthConfig = new OauthConfigEntity({ authEndpoint: `${hydraUri}/oauth2/auth`, clientId: tool.oAuthClientId, clientSecret: this.oAuthEncryptionService.encrypt(tool.secret), diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 368586af7c4..390ee95c5e5 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -6,16 +6,16 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningService } from '@modules/provisioning'; import { OauthConfigDto } from '@modules/system/service'; import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity, UserDO } from '@shared/domain'; +import { LegacySchoolDo, OauthConfigEntity, SchoolFeatures, SystemEntity, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; +import { legacySchoolDoFactory, setupEntities, systemEntityFactory, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { OauthDataDto } from '@src/modules/provisioning/dto'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { LegacySystemService } from '../../system/service/legacy-system.service'; import { OAuthTokenDto } from '../interface'; import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OauthTokenResponse } from './dto'; @@ -44,13 +44,13 @@ describe('OAuthService', () => { let oAuthEncryptionService: DeepMocked; let provisioningService: DeepMocked; let userService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let oauthAdapterService: DeepMocked; let migrationCheckService: DeepMocked; let schoolService: DeepMocked; let testSystem: SystemEntity; - let testOauthConfig: OauthConfig; + let testOauthConfig: OauthConfigEntity; const hostUri = 'https://mock.de'; @@ -81,8 +81,8 @@ describe('OAuthService', () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: OauthAdapterService, @@ -99,7 +99,7 @@ describe('OAuthService', () => { oAuthEncryptionService = module.get(DefaultEncryptionService); provisioningService = module.get(ProvisioningService); userService = module.get(UserService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); oauthAdapterService = module.get(OauthAdapterService); migrationCheckService = module.get(MigrationCheckService); schoolService = module.get(LegacySchoolService); @@ -124,8 +124,8 @@ describe('OAuthService', () => { } }); - testSystem = systemFactory.withOauthConfig().buildWithId(); - testOauthConfig = testSystem.oauthConfig as OauthConfig; + testSystem = systemEntityFactory.withOauthConfig().buildWithId(); + testOauthConfig = testSystem.oauthConfig as OauthConfigEntity; }); describe('requestToken', () => { diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 299198ef33a..5f13511766c 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,13 +1,13 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { LegacySchoolService } from '@modules/legacy-school'; import { OauthDataDto, ProvisioningService } from '@modules/provisioning'; -import { SystemService } from '@modules/system'; +import { LegacySystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { EntityId, LegacySchoolDo, OauthConfig, SchoolFeatures, UserDO } from '@shared/domain'; +import { EntityId, LegacySchoolDo, OauthConfigEntity, SchoolFeatures, UserDO } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; @@ -24,7 +24,7 @@ export class OAuthService { @Inject(DefaultEncryptionService) private readonly oAuthEncryptionService: EncryptionService, private readonly logger: LegacyLogger, private readonly provisioningService: ProvisioningService, - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly migrationCheckService: MigrationCheckService, private readonly schoolService: LegacySchoolService ) { @@ -118,7 +118,7 @@ export class OAuthService { return !!school.features?.includes(SchoolFeatures.OAUTH_PROVISIONING_ENABLED); } - async requestToken(code: string, oauthConfig: OauthConfig, redirectUri: string): Promise { + async requestToken(code: string, oauthConfig: OauthConfigEntity, redirectUri: string): Promise { const payload: AuthenticationCodeGrantTokenRequest = this.buildTokenRequestPayload(code, oauthConfig, redirectUri); const responseToken: OauthTokenResponse = await this.oauthAdapterService.sendAuthenticationCodeTokenRequest( @@ -130,7 +130,7 @@ export class OAuthService { return tokenDto; } - async validateToken(idToken: string, oauthConfig: OauthConfig): Promise { + async validateToken(idToken: string, oauthConfig: OauthConfigEntity): Promise { const publicKey: string = await this.oauthAdapterService.getPublicKey(oauthConfig.jwksEndpoint); const decodedJWT: string | JwtPayload = jwt.verify(idToken, publicKey, { algorithms: ['RS256'], @@ -147,7 +147,7 @@ export class OAuthService { private buildTokenRequestPayload( code: string, - oauthConfig: OauthConfig, + oauthConfig: OauthConfigEntity, redirectUri: string ): AuthenticationCodeGrantTokenRequest { const decryptedClientSecret: string = this.oAuthEncryptionService.decrypt(oauthConfig.clientSecret); diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts index b889995b9e5..e66be61ef83 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts @@ -1,19 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HydraSsoService, OAuthService } from '@modules/oauth'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { HttpModule } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; -import { OAuthService, HydraSsoService } from '@modules/oauth'; import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../loggable'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError } from '../loggable'; class HydraOauthUcSpec extends HydraOauthUc { public validateStatusSpec = (status: number) => this.validateStatus(status); @@ -40,7 +40,7 @@ describe('HydraOauthUc', () => { const hydraUri = 'hydraUri'; const apiHost = 'apiHost'; const nextcloudScopes = 'nextcloudscope'; - const hydraOauthConfig = new OauthConfig({ + const hydraOauthConfig = new OauthConfigEntity({ authEndpoint: `${hydraUri}/oauth2/auth`, clientId: 'toolClientId', clientSecret: 'toolSecret', diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index 2c461e6db4d..258fb2fb7d5 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -1,6 +1,6 @@ import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; @@ -28,7 +28,7 @@ export class HydraOauthUc { error || 'sso_auth_code_step' ); } - const hydraOauthConfig: OauthConfig = await this.hydraSsoService.generateConfig(oauthClientId); + const hydraOauthConfig: OauthConfigEntity = await this.hydraSsoService.generateConfig(oauthClientId); const oauthTokens: OAuthTokenDto = await this.oauthService.requestToken( code, @@ -44,7 +44,7 @@ export class HydraOauthUc { protected validateStatus = (status: number): boolean => status === 200 || status === 302; async requestAuthCode(userId: string, jwt: string, oauthClientId: string): Promise { - const hydraOauthConfig: OauthConfig = await this.hydraSsoService.generateConfig(oauthClientId); + const hydraOauthConfig: OauthConfigEntity = await this.hydraSsoService.generateConfig(oauthClientId); const axiosConfig: AxiosRequestConfig = { headers: {}, withCredentials: true, diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts index 1d80c6c7b90..1664c649b10 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { SystemDto } from '@modules/system/service/dto/system.dto'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; +import { LegacySystemService } from '../../system/service/legacy-system.service'; import { ExternalUserDto, OauthDataDto, @@ -18,7 +18,7 @@ describe('ProvisioningService', () => { let module: TestingModule; let service: ProvisioningService; - let systemService: DeepMocked; + let systemService: DeepMocked; let provisioningStrategy: DeepMocked; beforeAll(async () => { @@ -26,8 +26,8 @@ describe('ProvisioningService', () => { providers: [ ProvisioningService, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: SanisProvisioningStrategy, @@ -57,7 +57,7 @@ describe('ProvisioningService', () => { }).compile(); service = module.get(ProvisioningService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); provisioningStrategy = module.get(SanisProvisioningStrategy); }); diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.ts b/apps/server/src/modules/provisioning/service/provisioning.service.ts index 50ee527001c..8f7330645b5 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.ts @@ -1,7 +1,7 @@ +import { LegacySystemService } from '@modules/system'; +import { SystemDto } from '@modules/system/service/dto/system.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; import { OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto, ProvisioningSystemDto } from '../dto'; import { ProvisioningSystemInputMapper } from '../mapper/provisioning-system-input.mapper'; import { @@ -19,7 +19,7 @@ export class ProvisioningService { >(); constructor( - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly sanisStrategy: SanisProvisioningStrategy, private readonly iservStrategy: IservProvisioningStrategy, private readonly oidcMockStrategy: OidcMockProvisioningStrategy diff --git a/apps/server/src/modules/registration-pin/entity/index.ts b/apps/server/src/modules/registration-pin/entity/index.ts new file mode 100644 index 00000000000..ed20550896f --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity'; diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts new file mode 100644 index 00000000000..c8570e8d1b2 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts @@ -0,0 +1,57 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RegistrationPinEntity } from '.'; + +describe(RegistrationPinEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + email: 'test@test.eu', + pin: 'test123', + verified: false, + importHash: '02a00804nnQbLbCDEMVuk56pzZ3A0SC2cYnmM9cyY25IVOnf0K3YCKqW6zxC', + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new RegistrationPinEntity(); + expect(test).toThrow(); + }); + + it('should create a registrationPins by passing required properties', () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + expect(entity instanceof RegistrationPinEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + const entityProps = { + id: entity.id, + email: entity.email, + pin: entity.pin, + verified: entity.verified, + importHash: entity.importHash, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts new file mode 100644 index 00000000000..ee5ece7a421 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Index, Property } from '@mikro-orm/core'; +import { EntityId } from '@shared/domain'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; + +export interface RegistrationPinEntityProps { + id?: EntityId; + email: string; + pin: string; + verified: boolean; + importHash: string; +} + +@Entity({ tableName: 'registrationpins' }) +@Index({ properties: ['email', 'pin'] }) +export class RegistrationPinEntity extends BaseEntityWithTimestamps { + @Property() + @Index() + email: string; + + @Property() + pin: string; + + @Property({ default: false }) + verified: boolean; + + @Property() + @Index() + importHash: string; + + constructor(props: RegistrationPinEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + this.email = props.email; + this.pin = props.pin; + this.verified = props.verified; + this.importHash = props.importHash; + } +} diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts new file mode 100644 index 00000000000..74b1134fc78 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity.factory'; diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts new file mode 100644 index 00000000000..9a162147bed --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RegistrationPinEntity, RegistrationPinEntityProps } from '../../registration-pin.entity'; + +export const registrationPinEntityFactory = BaseFactory.define( + RegistrationPinEntity, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + email: `name-${sequence}@schul-cloud.org`, + pin: `123-${sequence}`, + verified: false, + importHash: `importHash-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/registration-pin/entity/testing/index.ts b/apps/server/src/modules/registration-pin/entity/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/registration-pin/index.ts b/apps/server/src/modules/registration-pin/index.ts new file mode 100644 index 00000000000..89a77b2fa2c --- /dev/null +++ b/apps/server/src/modules/registration-pin/index.ts @@ -0,0 +1,2 @@ +export * from './registration-pin.module'; +export { RegistrationPinService } from './service'; diff --git a/apps/server/src/modules/registration-pin/registration-pin.module.ts b/apps/server/src/modules/registration-pin/registration-pin.module.ts new file mode 100644 index 00000000000..76fa8716c94 --- /dev/null +++ b/apps/server/src/modules/registration-pin/registration-pin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { RegistrationPinService } from './service'; +import { RegistrationPinRepo } from './repo'; + +@Module({ + imports: [LoggerModule], + providers: [RegistrationPinService, RegistrationPinRepo], + exports: [RegistrationPinService], +}) +export class RegistrationPinModule {} diff --git a/apps/server/src/modules/registration-pin/repo/index.ts b/apps/server/src/modules/registration-pin/repo/index.ts new file mode 100644 index 00000000000..e32bd34f567 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.repo'; diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts new file mode 100644 index 00000000000..c357351fa37 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts @@ -0,0 +1,64 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections, userFactory } from '@shared/testing'; +import { RegistrationPinRepo } from '.'; +import { registrationPinEntityFactory } from '../entity/testing'; + +describe(RegistrationPinRepo.name, () => { + let module: TestingModule; + let repo: RegistrationPinRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RegistrationPinRepo], + }).compile(); + + repo = module.get(RegistrationPinRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('deleteRegistrationPinByEmail', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const userWithoutRegistrationPin = userFactory.buildWithId(); + const registrationPinForUser = registrationPinEntityFactory.buildWithId({ email: user.email }); + + await em.persistAndFlush(registrationPinForUser); + + return { + user, + userWithoutRegistrationPin, + }; + }; + + describe('when registrationPin exists', () => { + it('should delete registrationPins by email', async () => { + const { user } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + + describe('when there is no registrationPin', () => { + it('should return empty array', async () => { + const { userWithoutRegistrationPin } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(userWithoutRegistrationPin.email); + expect(result).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts new file mode 100644 index 00000000000..6ca68bc089d --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts @@ -0,0 +1,14 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { RegistrationPinEntity } from '../entity'; + +@Injectable() +export class RegistrationPinRepo { + constructor(private readonly em: EntityManager) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + const promise: Promise = this.em.nativeDelete(RegistrationPinEntity, { email }); + + return promise; + } +} diff --git a/apps/server/src/modules/registration-pin/service/index.ts b/apps/server/src/modules/registration-pin/service/index.ts new file mode 100644 index 00000000000..c8eea287110 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.service'; diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts new file mode 100644 index 00000000000..b5c6a2f3296 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; +import { RegistrationPinService } from '.'; +import { RegistrationPinRepo } from '../repo'; + +describe(RegistrationPinService.name, () => { + let module: TestingModule; + let service: RegistrationPinService; + let registrationPinRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RegistrationPinService, + { + provide: RegistrationPinRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RegistrationPinService); + registrationPinRepo = module.get(RegistrationPinRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteRegistrationPinByEmail', () => { + describe('when deleting registrationPin', () => { + const setup = () => { + const user = userDoFactory.buildWithId(); + + registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(1); + + return { + user, + }; + }; + + it('should call registrationPinRep', async () => { + const { user } = setup(); + + await service.deleteRegistrationPinByEmail(user.email); + + expect(registrationPinRepo.deleteRegistrationPinByEmail).toBeCalledWith(user.email); + }); + + it('should delete registrationPin by email', async () => { + const { user } = setup(); + + const result: number = await service.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts new file mode 100644 index 00000000000..4681b08329c --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { RegistrationPinRepo } from '../repo'; + +@Injectable() +export class RegistrationPinService { + constructor(private readonly registrationPinRepo: RegistrationPinRepo) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + return this.registrationPinRepo.deleteRegistrationPinByEmail(email); + } +} diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts index dd8ae17667c..57d7c2da254 100644 --- a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -62,7 +62,7 @@ describe(RocketChatUserService.name, () => { }); }); - describe('deleteUserDataFromClasses', () => { + describe('delete RocketChatUser', () => { describe('when deleting rocketChatUser', () => { const setup = () => { const userId = new ObjectId().toHexString(); diff --git a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts index 3167558461d..a449db0ffc9 100644 --- a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts +++ b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts @@ -1,54 +1,40 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfig, SystemEntity } from '@shared/domain'; -import { cleanupCollections, systemFactory } from '@shared/testing'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { PublicSystemListResponse } from '../dto/public-system-list.response'; -import { PublicSystemResponse } from '../dto/public-system-response'; +import { OauthConfigEntity, SchoolEntity, SystemEntity } from '@shared/domain'; +import { schoolFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Response } from 'supertest'; +import { PublicSystemListResponse, PublicSystemResponse } from '../dto'; + +const baseRouteName = '/systems'; describe('System (API)', () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; beforeAll(async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ + const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); - - app = moduleRef.createNestApplication(); + }).compile(); + + app = module.createNestApplication(); await app.init(); - em = app.get(EntityManager); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); }); afterAll(async () => { await app.close(); }); - afterEach(async () => { - await cleanupCollections(em); - }); - describe('[GET] systems/public', () => { describe('when the endpoint is called', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.withOauthConfig().buildWithId(); - const system2OauthConfig: OauthConfig = system2.oauthConfig as OauthConfig; + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId(); + const system2OauthConfig: OauthConfigEntity = system2.oauthConfig as OauthConfigEntity; await em.persistAndFlush([system1, system2]); em.clear(); @@ -59,7 +45,7 @@ describe('System (API)', () => { it('should return a list of all systems', async () => { const { system1, system2, system2OauthConfig } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/systems/public`).expect(200); + const response: Response = await testApiClient.get(`/public`).expect(200); expect(response.body).toEqual({ data: [ @@ -98,8 +84,8 @@ describe('System (API)', () => { describe('[GET] systems/public/:systemId', () => { describe('when the endpoint is called with a known systemId', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.buildWithId(); + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.buildWithId(); await em.persistAndFlush([system1, system2]); em.clear(); @@ -110,7 +96,7 @@ describe('System (API)', () => { it('should return the system', async () => { const { system1 } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/systems/public/${system1.id}`).expect(200); + const response: Response = await testApiClient.get(`/public/${system1.id}`).expect(200); expect(response.body).toEqual({ id: system1.id, @@ -121,4 +107,41 @@ describe('System (API)', () => { }); }); }); + + describe('[DELETE] systems/:systemId', () => { + describe('when the endpoint is called with a known systemId', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.withLdapConfig({ provider: 'general' }).buildWithId(); + const school: SchoolEntity = schoolFactory.build({ systems: [system] }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([system, adminAccount, adminUser, school]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + system, + adminClient, + }; + }; + + it('should delete the system', async () => { + const { system, adminClient } = await setup(); + + const response = await adminClient.delete(system.id); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); + }); + }); + + describe('when not authenticated', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.delete(new ObjectId().toHexString()); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); }); diff --git a/apps/server/src/modules/system/controller/dto/index.ts b/apps/server/src/modules/system/controller/dto/index.ts new file mode 100644 index 00000000000..9ff34a1b421 --- /dev/null +++ b/apps/server/src/modules/system/controller/dto/index.ts @@ -0,0 +1,5 @@ +export { SystemIdParams } from './system-id.params'; +export { SystemFilterParams } from './system.filter.params'; +export { OauthConfigResponse } from './oauth-config.response'; +export { PublicSystemResponse } from './public-system-response'; +export { PublicSystemListResponse } from './public-system-list.response'; diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index bbeca71b83c..6eabcc53762 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -1,11 +1,9 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { SystemFilterParams } from '@modules/system/controller/dto/system.filter.params'; +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; +import { ApiForbiddenResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { SystemDto } from '../service'; import { SystemUc } from '../uc/system.uc'; -import { PublicSystemListResponse } from './dto/public-system-list.response'; -import { PublicSystemResponse } from './dto/public-system-response'; -import { SystemIdParams } from './dto/system-id.params'; +import { PublicSystemListResponse, PublicSystemResponse, SystemFilterParams, SystemIdParams } from './dto'; import { SystemResponseMapper } from './mapper/system-response.mapper'; @ApiTags('Systems') @@ -42,4 +40,14 @@ export class SystemController { return mapped; } + + @Authenticate('jwt') + @Delete(':systemId') + @ApiForbiddenResponse() + @ApiUnauthorizedResponse() + @ApiOperation({ summary: 'Deletes a system.' }) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteSystem(@CurrentUser() currentUser: ICurrentUser, @Param() params: SystemIdParams): Promise { + await this.systemUc.delete(currentUser.userId, params.systemId); + } } diff --git a/apps/server/src/modules/system/domain/index.ts b/apps/server/src/modules/system/domain/index.ts new file mode 100644 index 00000000000..16e2a044335 --- /dev/null +++ b/apps/server/src/modules/system/domain/index.ts @@ -0,0 +1,3 @@ +export { System, SystemProps } from './system.do'; +export { LdapConfig } from './ldap-config'; +export { OauthConfig } from './oauth-config'; diff --git a/apps/server/src/modules/system/domain/ldap-config.ts b/apps/server/src/modules/system/domain/ldap-config.ts new file mode 100644 index 00000000000..137d1fc92f6 --- /dev/null +++ b/apps/server/src/modules/system/domain/ldap-config.ts @@ -0,0 +1,13 @@ +export class LdapConfig { + active: boolean; + + url: string; + + provider?: string; + + constructor(props: LdapConfig) { + this.active = props.active; + this.url = props.url; + this.provider = props.provider; + } +} diff --git a/apps/server/src/modules/system/domain/oauth-config.ts b/apps/server/src/modules/system/domain/oauth-config.ts new file mode 100644 index 00000000000..165691a0797 --- /dev/null +++ b/apps/server/src/modules/system/domain/oauth-config.ts @@ -0,0 +1,46 @@ +export class OauthConfig { + clientId: string; + + clientSecret: string; + + idpHint?: string; + + redirectUri: string; + + grantType: string; + + tokenEndpoint: string; + + authEndpoint: string; + + responseType: string; + + scope: string; + + provider: string; + + /** + * If this is set it will be used to redirect the user after login to the logout endpoint of the identity provider. + */ + logoutEndpoint?: string; + + issuer: string; + + jwksEndpoint: string; + + constructor(oauthConfigDto: OauthConfig) { + this.clientId = oauthConfigDto.clientId; + this.clientSecret = oauthConfigDto.clientSecret; + this.idpHint = oauthConfigDto.idpHint; + this.redirectUri = oauthConfigDto.redirectUri; + this.grantType = oauthConfigDto.grantType; + this.tokenEndpoint = oauthConfigDto.tokenEndpoint; + this.authEndpoint = oauthConfigDto.authEndpoint; + this.responseType = oauthConfigDto.responseType; + this.scope = oauthConfigDto.scope; + this.provider = oauthConfigDto.provider; + this.logoutEndpoint = oauthConfigDto.logoutEndpoint; + this.issuer = oauthConfigDto.issuer; + this.jwksEndpoint = oauthConfigDto.jwksEndpoint; + } +} diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts new file mode 100644 index 00000000000..f909d3196e9 --- /dev/null +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -0,0 +1,28 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { LdapConfig } from './ldap-config'; +import { OauthConfig } from './oauth-config'; + +export interface SystemProps extends AuthorizableObject { + type: string; + + url?: string; + + alias?: string; + + displayName?: string; + + provisioningStrategy?: SystemProvisioningStrategy; + + provisioningUrl?: string; + + oauthConfig?: OauthConfig; + + ldapConfig?: LdapConfig; +} + +export class System extends DomainObject { + get ldapConfig(): LdapConfig | undefined { + return this.props.ldapConfig; + } +} diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 2e912fd9246..9cedfa41938 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -1,2 +1,3 @@ export * from './system.module'; -export * from './service'; +export { SystemService, LegacySystemService, SystemDto, OauthConfigDto, OidcConfigDto } from './service'; +export { System, SystemProps, OauthConfig, LdapConfig } from './domain'; diff --git a/apps/server/src/modules/system/mapper/index.ts b/apps/server/src/modules/system/mapper/index.ts new file mode 100644 index 00000000000..9dc568e504c --- /dev/null +++ b/apps/server/src/modules/system/mapper/index.ts @@ -0,0 +1,2 @@ +export { SystemMapper } from './system.mapper'; +export { SystemOidcMapper } from './system-oidc.mapper'; diff --git a/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts b/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts index 2486d4a1872..0b230c87eed 100644 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts +++ b/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemOidcMapper } from './system-oidc.mapper'; describe('SystemOidcMapper', () => { let module: TestingModule; @@ -18,7 +18,7 @@ describe('SystemOidcMapper', () => { describe('mapFromEntityToDto', () => { it('should map all fields', () => { - const systemEntity = systemFactory.withOauthConfig().withOidcConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); expect(result).toBeDefined(); @@ -34,7 +34,7 @@ describe('SystemOidcMapper', () => { expect(result?.defaultScopes).toEqual(systemEntity.oidcConfig?.defaultScopes); }); it('should return undefined if parent system has no oidc config', () => { - const systemEntity = systemFactory.withOauthConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().build(); const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); expect(result).toBeUndefined(); }); @@ -43,8 +43,8 @@ describe('SystemOidcMapper', () => { describe('mapFromEntitiesToDtos', () => { it('should map all given entities', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOidcConfig().build(), - systemFactory.withOidcConfig().build(), + systemEntityFactory.withOidcConfig().build(), + systemEntityFactory.withOidcConfig().build(), ]; const result = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); @@ -53,8 +53,8 @@ describe('SystemOidcMapper', () => { }); it('should map oidc config only config if exists', () => { - const systemEntity = systemFactory.withOidcConfig().build(); - const systemEntities: SystemEntity[] = [systemEntity, systemFactory.withOauthConfig().build()]; + const systemEntity = systemEntityFactory.withOidcConfig().build(); + const systemEntities: SystemEntity[] = [systemEntity, systemEntityFactory.withOauthConfig().build()]; const results = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); diff --git a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts b/apps/server/src/modules/system/mapper/system-oidc.mapper.ts index 8726ce09a78..e486ae458fd 100644 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts +++ b/apps/server/src/modules/system/mapper/system-oidc.mapper.ts @@ -1,5 +1,5 @@ -import { OidcConfig, SystemEntity } from '@shared/domain'; import { OidcConfigDto } from '@modules/system/service/dto/oidc-config.dto'; +import { OidcConfigEntity, SystemEntity } from '@shared/domain'; export class SystemOidcMapper { static mapFromEntityToDto(entity: SystemEntity): OidcConfigDto | undefined { @@ -9,7 +9,7 @@ export class SystemOidcMapper { return undefined; } - static mapFromOidcConfigEntityToDto(systemId: string, oidcConfig: OidcConfig): OidcConfigDto { + static mapFromOidcConfigEntityToDto(systemId: string, oidcConfig: OidcConfigEntity): OidcConfigDto { return new OidcConfigDto({ parentSystemId: systemId, clientId: oidcConfig.clientId, diff --git a/apps/server/src/modules/system/mapper/system.mapper.spec.ts b/apps/server/src/modules/system/mapper/system.mapper.spec.ts index 54c20cc0cff..8bbf4986d9e 100644 --- a/apps/server/src/modules/system/mapper/system.mapper.spec.ts +++ b/apps/server/src/modules/system/mapper/system.mapper.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemMapper } from './system.mapper'; describe('SystemMapper', () => { let module: TestingModule; @@ -18,7 +18,7 @@ describe('SystemMapper', () => { describe('mapFromEntityToDto', () => { it('should map all fields', () => { - const systemEntity = systemFactory.withOauthConfig().withOidcConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); const result = SystemMapper.mapFromEntityToDto(systemEntity); @@ -32,7 +32,7 @@ describe('SystemMapper', () => { }); it('should map take alias as default instead of displayName', () => { // Arrange - const systemEntity = systemFactory.withOauthConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().build(); systemEntity.displayName = undefined; // Act @@ -47,8 +47,8 @@ describe('SystemMapper', () => { describe('mapFromEntitiesToDtos', () => { it('should map all given entities', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOauthConfig().build(), - systemFactory.build({ oauthConfig: undefined }), + systemEntityFactory.withOauthConfig().build(), + systemEntityFactory.build({ oauthConfig: undefined }), ]; const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); @@ -58,8 +58,8 @@ describe('SystemMapper', () => { it('should map oauth config if exists', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOauthConfig().build(), - systemFactory.build({ oauthConfig: undefined }), + systemEntityFactory.withOauthConfig().build(), + systemEntityFactory.build({ oauthConfig: undefined }), ]; const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); diff --git a/apps/server/src/modules/system/mapper/system.mapper.ts b/apps/server/src/modules/system/mapper/system.mapper.ts index ae29fea67c8..420de9f2a69 100644 --- a/apps/server/src/modules/system/mapper/system.mapper.ts +++ b/apps/server/src/modules/system/mapper/system.mapper.ts @@ -1,6 +1,6 @@ -import { OauthConfig, SystemEntity } from '@shared/domain'; import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { OauthConfigEntity, SystemEntity } from '@shared/domain'; export class SystemMapper { static mapFromEntityToDto(entity: SystemEntity): SystemDto { @@ -17,7 +17,7 @@ export class SystemMapper { }); } - static mapFromOauthConfigEntityToDto(oauthConfig: OauthConfig | undefined): OauthConfigDto | undefined { + static mapFromOauthConfigEntityToDto(oauthConfig: OauthConfigEntity | undefined): OauthConfigDto | undefined { if (!oauthConfig) return undefined; return new OauthConfigDto({ clientId: oauthConfig.clientId, diff --git a/apps/server/src/modules/system/repo/index.ts b/apps/server/src/modules/system/repo/index.ts new file mode 100644 index 00000000000..7bf41b20479 --- /dev/null +++ b/apps/server/src/modules/system/repo/index.ts @@ -0,0 +1 @@ +export { SystemRepo } from './system.repo'; diff --git a/apps/server/src/modules/system/repo/system-domain.mapper.ts b/apps/server/src/modules/system/repo/system-domain.mapper.ts new file mode 100644 index 00000000000..422f838c0ed --- /dev/null +++ b/apps/server/src/modules/system/repo/system-domain.mapper.ts @@ -0,0 +1,50 @@ +import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain'; +import { LdapConfig, OauthConfig, SystemProps } from '../domain'; + +export class SystemDomainMapper { + public static mapEntityToDomainObjectProperties(entity: SystemEntity): SystemProps { + const mapped: SystemProps = { + id: entity.id, + url: entity.url, + type: entity.type, + provisioningUrl: entity.provisioningUrl, + provisioningStrategy: entity.provisioningStrategy, + displayName: entity.displayName, + alias: entity.alias, + oauthConfig: entity.oauthConfig ? this.mapOauthConfigEntityToDomainObject(entity.oauthConfig) : undefined, + ldapConfig: entity.ldapConfig ? this.mapLdapConfigEntityToDomainObject(entity.ldapConfig) : undefined, + }; + + return mapped; + } + + private static mapOauthConfigEntityToDomainObject(oauthConfig: OauthConfigEntity): OauthConfig { + const mapped: OauthConfig = new OauthConfig({ + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + idpHint: oauthConfig.idpHint, + authEndpoint: oauthConfig.authEndpoint, + responseType: oauthConfig.responseType, + scope: oauthConfig.scope, + provider: oauthConfig.provider, + logoutEndpoint: oauthConfig.logoutEndpoint, + issuer: oauthConfig.issuer, + jwksEndpoint: oauthConfig.jwksEndpoint, + grantType: oauthConfig.grantType, + tokenEndpoint: oauthConfig.tokenEndpoint, + redirectUri: oauthConfig.redirectUri, + }); + + return mapped; + } + + private static mapLdapConfigEntityToDomainObject(ldapConfig: LdapConfigEntity): LdapConfig { + const mapped: LdapConfig = new LdapConfig({ + active: !!ldapConfig.active, + url: ldapConfig.url, + provider: ldapConfig.provider, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/system/repo/system.repo.spec.ts b/apps/server/src/modules/system/repo/system.repo.spec.ts new file mode 100644 index 00000000000..584035c0e46 --- /dev/null +++ b/apps/server/src/modules/system/repo/system.repo.spec.ts @@ -0,0 +1,177 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { cleanupCollections, systemEntityFactory } from '@shared/testing'; +import { System, SystemProps } from '../domain'; +import { SystemDomainMapper } from './system-domain.mapper'; +import { SystemRepo } from './system.repo'; + +describe(SystemRepo.name, () => { + let module: TestingModule; + let repo: SystemRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [SystemRepo], + }).compile(); + + repo = module.get(SystemRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findById', () => { + describe('when the system exists', () => { + const setup = async () => { + const oauthConfig = new OauthConfigEntity({ + clientId: '12345', + clientSecret: 'mocksecret', + idpHint: 'mock-oauth-idpHint', + tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + grantType: 'authorization_code', + redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + scope: 'openid uuid', + responseType: 'code', + authEndpoint: 'http://mock.de/auth', + provider: 'mock_type', + logoutEndpoint: 'http://mock.de/logout', + issuer: 'mock_issuer', + jwksEndpoint: 'http://mock.de/jwks', + }); + const ldapConfig = new LdapConfigEntity({ + url: 'ldaps:mock.de:389', + active: true, + provider: 'mock_provider', + }); + const system: SystemEntity = systemEntityFactory.buildWithId({ + type: 'oauth', + url: 'https://mock.de', + alias: 'alias', + displayName: 'displayName', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + provisioningUrl: 'https://provisioningurl.de', + oauthConfig, + ldapConfig, + }); + + await em.persistAndFlush([system]); + em.clear(); + + return { + system, + oauthConfig, + ldapConfig, + }; + }; + + it('should return the system', async () => { + const { system, oauthConfig, ldapConfig } = await setup(); + + const result = await repo.findById(system.id); + + expect(result?.getProps()).toEqual({ + id: system.id, + type: system.type, + url: system.url, + displayName: system.displayName, + alias: system.alias, + provisioningStrategy: system.provisioningStrategy, + provisioningUrl: system.provisioningUrl, + oauthConfig: { + issuer: oauthConfig.issuer, + provider: oauthConfig.provider, + jwksEndpoint: oauthConfig.jwksEndpoint, + redirectUri: oauthConfig.redirectUri, + idpHint: oauthConfig.idpHint, + authEndpoint: oauthConfig.authEndpoint, + clientSecret: oauthConfig.clientSecret, + grantType: oauthConfig.grantType, + logoutEndpoint: oauthConfig.logoutEndpoint, + responseType: oauthConfig.responseType, + tokenEndpoint: oauthConfig.tokenEndpoint, + clientId: oauthConfig.clientId, + scope: oauthConfig.scope, + }, + ldapConfig: { + url: ldapConfig.url, + provider: ldapConfig.provider, + active: !!ldapConfig.active, + }, + }); + }); + }); + + describe('when the system does not exist', () => { + it('should return null', async () => { + const result = await repo.findById(new ObjectId().toHexString()); + + expect(result).toBeNull(); + }); + }); + }); + + describe('delete', () => { + describe('when the system exists', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([systemEntity]); + em.clear(); + + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should delete the system', async () => { + const { system } = await setup(); + + await repo.delete(system); + + expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); + }); + + it('should return true', async () => { + const { system } = await setup(); + + const result = await repo.delete(system); + + expect(result).toEqual(true); + }); + }); + + describe('when the system does not exists', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should return false', async () => { + const { system } = setup(); + + const result = await repo.delete(system); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/repo/system.repo.ts b/apps/server/src/modules/system/repo/system.repo.ts new file mode 100644 index 00000000000..3251ec87230 --- /dev/null +++ b/apps/server/src/modules/system/repo/system.repo.ts @@ -0,0 +1,36 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SystemEntity } from '@shared/domain'; +import { System, SystemProps } from '../domain'; +import { SystemDomainMapper } from './system-domain.mapper'; + +@Injectable() +export class SystemRepo { + constructor(private readonly em: EntityManager) {} + + public async findById(id: EntityId): Promise { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id }); + + if (!entity) { + return null; + } + + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(entity); + + const domainObject: System = new System(props); + + return domainObject; + } + + public async delete(domainObject: System): Promise { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id: domainObject.id }); + + if (!entity) { + return false; + } + + await this.em.removeAndFlush(entity); + + return true; + } +} diff --git a/apps/server/src/modules/system/service/dto/system.dto.ts b/apps/server/src/modules/system/service/dto/system.dto.ts index 1ea7e4a84ee..bdffdf75423 100644 --- a/apps/server/src/modules/system/service/dto/system.dto.ts +++ b/apps/server/src/modules/system/service/dto/system.dto.ts @@ -1,6 +1,6 @@ -import { EntityId } from '@shared/domain'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { EntityId } from '@shared/domain/types'; export class SystemDto { id?: EntityId; diff --git a/apps/server/src/modules/system/service/index.ts b/apps/server/src/modules/system/service/index.ts index f0956aa12ad..6be1d3fb0fa 100644 --- a/apps/server/src/modules/system/service/index.ts +++ b/apps/server/src/modules/system/service/index.ts @@ -1,2 +1,4 @@ -export * from './system.service'; -export * from './dto'; +export { LegacySystemService } from './legacy-system.service'; +export { SystemDto, OauthConfigDto, OidcConfigDto } from './dto'; +export { SystemService } from './system.service'; +export { SystemOidcService } from './system-oidc.service'; diff --git a/apps/server/src/modules/system/service/legacy-system.service.spec.ts b/apps/server/src/modules/system/service/legacy-system.service.spec.ts new file mode 100644 index 00000000000..b600e4d2891 --- /dev/null +++ b/apps/server/src/modules/system/service/legacy-system.service.spec.ts @@ -0,0 +1,237 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { IdentityManagementOauthService } from '@infra/identity-management'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundError } from '@shared/common'; +import { OauthConfigEntity, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemMapper } from '../mapper'; +import { LegacySystemService } from './legacy-system.service'; + +describe(LegacySystemService.name, () => { + let module: TestingModule; + let systemService: LegacySystemService; + let systemRepoMock: DeepMocked; + let kcIdmOauthServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LegacySystemService, + { + provide: LegacySystemRepo, + useValue: createMock(), + }, + { + provide: IdentityManagementOauthService, + useValue: createMock(), + }, + ], + }).compile(); + systemRepoMock = module.get(LegacySystemRepo); + systemService = module.get(LegacySystemService); + kcIdmOauthServiceMock = module.get(IdentityManagementOauthService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findById', () => { + describe('when identity management is available', () => { + const standaloneSystem = systemEntityFactory.buildWithId({ alias: 'standaloneSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const setup = (system: SystemEntity) => { + systemRepoMock.findById.mockResolvedValue(system); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); + kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfigEntity); + }; + + it('should return found system', async () => { + setup(standaloneSystem); + const result = await systemService.findById(standaloneSystem.id); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); + }); + + it('should return found system with generated oauth config for oidc systems', async () => { + setup(oidcSystem); + if (oauthSystem.oauthConfig === undefined) { + fail('oauth system has no oauth configuration'); + } + const result = await systemService.findById(oidcSystem.id); + expect(result).toEqual( + expect.objectContaining({ + id: oidcSystem.id, + type: SystemTypeEnum.OAUTH, + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + url: oidcSystem.url, + provisioningStrategy: oidcSystem.provisioningStrategy, + provisioningUrl: oidcSystem.provisioningUrl, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + oauthConfig: expect.objectContaining({ + clientId: oauthSystem.oauthConfig.clientId, + clientSecret: oauthSystem.oauthConfig.clientSecret, + idpHint: oidcSystem.oidcConfig?.idpHint, + redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, + grantType: oauthSystem.oauthConfig.grantType, + tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, + authEndpoint: oauthSystem.oauthConfig.authEndpoint, + responseType: oauthSystem.oauthConfig.responseType, + scope: oauthSystem.oauthConfig.scope, + provider: oauthSystem.oauthConfig.provider, + logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, + issuer: oauthSystem.oauthConfig.issuer, + jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, + }), + }) + ); + }); + }); + + describe('when identity management is not available', () => { + const standaloneSystem = systemEntityFactory.buildWithId(); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId(); + const setup = (system: SystemEntity) => { + systemRepoMock.findById.mockResolvedValue(system); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + }; + + it('should return found system', async () => { + setup(standaloneSystem); + const result = await systemService.findById(standaloneSystem.id); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); + }); + + it('should throw and not generate oauth config for oidc systems', async () => { + setup(oidcSystem); + await expect(systemService.findById(oidcSystem.id)).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('findByType', () => { + describe('when identity management is available', () => { + const ldapSystem = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const setup = () => { + systemRepoMock.findAll.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); + systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { + if (type === SystemTypeEnum.LDAP) return Promise.resolve([ldapSystem]); + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); + if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); + return Promise.resolve([]); + }); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); + kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfigEntity); + }; + + it('should return all systems', async () => { + setup(); + const result = await systemService.findByType(); + expect(result).toEqual( + expect.arrayContaining([ + ...SystemMapper.mapFromEntitiesToDtos([ldapSystem, oauthSystem]), + expect.objectContaining({ + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + }), + ]) + ); + }); + + it('should return found systems', async () => { + setup(); + const result = await systemService.findByType(SystemTypeEnum.LDAP); + expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([ldapSystem])); + }); + + it('should return found systems with generated oauth config for oidc systems', async () => { + setup(); + if (oauthSystem.oauthConfig === undefined) { + fail('oauth system has no oauth configuration'); + } + const resultingSystems = await systemService.findByType(SystemTypeEnum.OAUTH); + + expect(resultingSystems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: SystemTypeEnum.OAUTH, + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + oauthConfig: expect.objectContaining({ + clientId: oauthSystem.oauthConfig.clientId, + clientSecret: oauthSystem.oauthConfig.clientSecret, + idpHint: oidcSystem.oidcConfig?.idpHint, + redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, + grantType: oauthSystem.oauthConfig.grantType, + tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, + authEndpoint: oauthSystem.oauthConfig.authEndpoint, + responseType: oauthSystem.oauthConfig.responseType, + scope: oauthSystem.oauthConfig.scope, + provider: oauthSystem.oauthConfig.provider, + logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, + issuer: oauthSystem.oauthConfig.issuer, + jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, + }), + }), + ]) + ); + }); + }); + + describe('when identity management is not available', () => { + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId(); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId(); + const setup = () => { + systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); + if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); + return Promise.resolve([]); + }); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + }; + it('should filter out oidc systems', async () => { + setup(); + const result = await systemService.findByType(SystemTypeEnum.OAUTH); + expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([oauthSystem])); + }); + }); + }); + + describe('save', () => { + describe('when creating a new system', () => { + const newSystem = systemEntityFactory.build(); + const setup = () => { + systemRepoMock.save.mockResolvedValue(); + }; + + it('should save new system', async () => { + setup(); + const result = await systemService.save(newSystem); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(newSystem)); + }); + }); + + describe('when updating an existing system', () => { + const existingSystem = systemEntityFactory.buildWithId(); + const setup = () => { + systemRepoMock.findById.mockResolvedValue(existingSystem); + }; + + it('should update existing system', async () => { + setup(); + const result = await systemService.save(existingSystem); + expect(systemRepoMock.findById).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(existingSystem)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/service/legacy-system.service.ts b/apps/server/src/modules/system/service/legacy-system.service.ts new file mode 100644 index 00000000000..9b59baad7f5 --- /dev/null +++ b/apps/server/src/modules/system/service/legacy-system.service.ts @@ -0,0 +1,95 @@ +import { IdentityManagementOauthService } from '@infra/identity-management'; +import { Injectable } from '@nestjs/common'; +import { EntityNotFoundError } from '@shared/common'; +import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemMapper } from '../mapper'; +import { SystemDto } from './dto'; + +// TODO N21-1547: Fully replace this service with SystemService +/** + * @deprecated use {@link SystemService} + */ +@Injectable() +export class LegacySystemService { + constructor( + private readonly systemRepo: LegacySystemRepo, + private readonly idmOauthService: IdentityManagementOauthService + ) {} + + async findById(id: EntityId): Promise { + let system = await this.systemRepo.findById(id); + [system] = await this.generateBrokerSystems([system]); + if (!system) { + throw new EntityNotFoundError(SystemEntity.name, { id }); + } + return SystemMapper.mapFromEntityToDto(system); + } + + async findByType(type?: SystemTypeEnum): Promise { + let systems: SystemEntity[]; + if (type && type === SystemTypeEnum.OAUTH) { + const oauthSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OAUTH); + const oidcSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OIDC); + systems = [...oauthSystems, ...oidcSystems]; + } else if (type) { + systems = await this.systemRepo.findByFilter(type); + } else { + systems = await this.systemRepo.findAll(); + } + systems = await this.generateBrokerSystems(systems); + return SystemMapper.mapFromEntitiesToDtos(systems); + } + + async save(systemDto: SystemDto): Promise { + let system: SystemEntity; + if (systemDto.id) { + system = await this.systemRepo.findById(systemDto.id); + system.type = systemDto.type; + system.alias = systemDto.alias; + system.displayName = systemDto.displayName; + system.oauthConfig = systemDto.oauthConfig; + system.provisioningStrategy = systemDto.provisioningStrategy; + system.provisioningUrl = systemDto.provisioningUrl; + system.url = systemDto.url; + } else { + system = new SystemEntity({ + type: systemDto.type, + alias: systemDto.alias, + displayName: systemDto.displayName, + oauthConfig: systemDto.oauthConfig, + provisioningStrategy: systemDto.provisioningStrategy, + provisioningUrl: systemDto.provisioningUrl, + url: systemDto.url, + }); + } + await this.systemRepo.save(system); + return SystemMapper.mapFromEntityToDto(system); + } + + private async generateBrokerSystems(systems: SystemEntity[]): Promise<[] | SystemEntity[]> { + if (!(await this.idmOauthService.isOauthConfigAvailable())) { + return systems.filter((system) => !(system.oidcConfig && !system.oauthConfig)); + } + const brokerConfig = await this.idmOauthService.getOauthConfig(); + let generatedSystem: SystemEntity; + return systems.map((system) => { + if (system.oidcConfig && !system.oauthConfig) { + generatedSystem = new SystemEntity({ + type: SystemTypeEnum.OAUTH, + alias: system.alias, + displayName: system.displayName ? system.displayName : system.alias, + provisioningStrategy: system.provisioningStrategy, + provisioningUrl: system.provisioningUrl, + url: system.url, + }); + generatedSystem.id = system.id; + generatedSystem.oauthConfig = { ...brokerConfig }; + generatedSystem.oauthConfig.idpHint = system.oidcConfig.idpHint; + generatedSystem.oauthConfig.redirectUri += system.id; + return generatedSystem; + } + return system; + }); + } +} diff --git a/apps/server/src/modules/system/service/system-oidc.service.spec.ts b/apps/server/src/modules/system/service/system-oidc.service.spec.ts index 6d85d5fb730..53e73d2e780 100644 --- a/apps/server/src/modules/system/service/system-oidc.service.spec.ts +++ b/apps/server/src/modules/system/service/system-oidc.service.spec.ts @@ -2,27 +2,27 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { SystemEntity } from '@shared/domain'; -import { SystemRepo } from '@shared/repo'; -import { systemFactory } from '@shared/testing'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; import { SystemOidcMapper } from '../mapper/system-oidc.mapper'; import { SystemOidcService } from './system-oidc.service'; describe('SystemService', () => { let module: TestingModule; let systemService: SystemOidcService; - let systemRepoMock: DeepMocked; + let systemRepoMock: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ SystemOidcService, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, ], }).compile(); - systemRepoMock = module.get(SystemRepo); + systemRepoMock = module.get(LegacySystemRepo); systemService = module.get(SystemOidcService); }); @@ -35,8 +35,8 @@ describe('SystemService', () => { }); describe('findById', () => { - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); - const standaloneSystem = systemFactory.buildWithId({ alias: 'standaloneSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const standaloneSystem = systemEntityFactory.buildWithId({ alias: 'standaloneSystem' }); const setup = (system: SystemEntity) => { systemRepoMock.findById.mockResolvedValue(system); }; @@ -54,9 +54,9 @@ describe('SystemService', () => { }); describe('findAll', () => { - const ldapSystem = systemFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const ldapSystem = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); it('should return oidc systems only', async () => { systemRepoMock.findByFilter.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); diff --git a/apps/server/src/modules/system/service/system-oidc.service.ts b/apps/server/src/modules/system/service/system-oidc.service.ts index c1f1cf0a4c1..c8703da8ff6 100644 --- a/apps/server/src/modules/system/service/system-oidc.service.ts +++ b/apps/server/src/modules/system/service/system-oidc.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { SystemRepo } from '@shared/repo'; -import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemOidcMapper } from '../mapper'; import { OidcConfigDto } from './dto'; @Injectable() export class SystemOidcService { - constructor(private readonly systemRepo: SystemRepo) {} + constructor(private readonly systemRepo: LegacySystemRepo) {} async findById(id: EntityId): Promise { const system = await this.systemRepo.findById(id); diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index 89ef533058b..b565a74a2a2 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -1,18 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { OauthConfig, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@infra/identity-management'; -import { SystemRepo } from '@shared/repo'; import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '../mapper/system.mapper'; +import { SystemRepo } from '../repo'; import { SystemService } from './system.service'; -describe('SystemService', () => { +describe(SystemService.name, () => { let module: TestingModule; - let systemService: SystemService; - let systemRepoMock: DeepMocked; - let kcIdmOauthServiceMock: DeepMocked; + let service: SystemService; + + let systemRepo: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -22,15 +19,11 @@ describe('SystemService', () => { provide: SystemRepo, useValue: createMock(), }, - { - provide: IdentityManagementOauthService, - useValue: createMock(), - }, ], }).compile(); - systemRepoMock = module.get(SystemRepo); - systemService = module.get(SystemService); - kcIdmOauthServiceMock = module.get(IdentityManagementOauthService); + + service = module.get(SystemService); + systemRepo = module.get(SystemRepo); }); afterAll(async () => { @@ -38,199 +31,83 @@ describe('SystemService', () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe('findById', () => { - describe('when identity management is available', () => { - const standaloneSystem = systemFactory.buildWithId({ alias: 'standaloneSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const setup = (system: SystemEntity) => { - systemRepoMock.findById.mockResolvedValue(system); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); - kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfig); - }; + describe('when the system exists', () => { + const setup = () => { + const system = systemFactory.build(); - it('should return found system', async () => { - setup(standaloneSystem); - const result = await systemService.findById(standaloneSystem.id); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); - }); + systemRepo.findById.mockResolvedValueOnce(system); - it('should return found system with generated oauth config for oidc systems', async () => { - setup(oidcSystem); - if (oauthSystem.oauthConfig === undefined) { - fail('oauth system has no oauth configuration'); - } - const result = await systemService.findById(oidcSystem.id); - expect(result).toEqual( - expect.objectContaining({ - id: oidcSystem.id, - type: SystemTypeEnum.OAUTH, - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - url: oidcSystem.url, - provisioningStrategy: oidcSystem.provisioningStrategy, - provisioningUrl: oidcSystem.provisioningUrl, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - oauthConfig: expect.objectContaining({ - clientId: oauthSystem.oauthConfig.clientId, - clientSecret: oauthSystem.oauthConfig.clientSecret, - idpHint: oidcSystem.oidcConfig?.idpHint, - redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, - grantType: oauthSystem.oauthConfig.grantType, - tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, - authEndpoint: oauthSystem.oauthConfig.authEndpoint, - responseType: oauthSystem.oauthConfig.responseType, - scope: oauthSystem.oauthConfig.scope, - provider: oauthSystem.oauthConfig.provider, - logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, - issuer: oauthSystem.oauthConfig.issuer, - jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, - }), - }) - ); - }); - }); - - describe('when identity management is not available', () => { - const standaloneSystem = systemFactory.buildWithId(); - const oidcSystem = systemFactory.withOidcConfig().buildWithId(); - const setup = (system: SystemEntity) => { - systemRepoMock.findById.mockResolvedValue(system); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + return { + system, + }; }; - it('should return found system', async () => { - setup(standaloneSystem); - const result = await systemService.findById(standaloneSystem.id); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); - }); + it('should return the system', async () => { + const { system } = setup(); - it('should throw and not generate oauth config for oidc systems', async () => { - setup(oidcSystem); - await expect(systemService.findById(oidcSystem.id)).rejects.toThrow(EntityNotFoundError); + const result = await service.findById(system.id); + + expect(result).toEqual(system); }); }); - }); - describe('findByType', () => { - describe('when identity management is available', () => { - const ldapSystem = systemFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + describe('when the system does not exist', () => { const setup = () => { - systemRepoMock.findAll.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); - systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { - if (type === SystemTypeEnum.LDAP) return Promise.resolve([ldapSystem]); - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); - if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); - return Promise.resolve([]); - }); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); - kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfig); + systemRepo.findById.mockResolvedValueOnce(null); }; - it('should return all systems', async () => { + it('should return null', async () => { setup(); - const result = await systemService.findByType(); - expect(result).toEqual( - expect.arrayContaining([ - ...SystemMapper.mapFromEntitiesToDtos([ldapSystem, oauthSystem]), - expect.objectContaining({ - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - }), - ]) - ); - }); - it('should return found systems', async () => { - setup(); - const result = await systemService.findByType(SystemTypeEnum.LDAP); - expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([ldapSystem])); - }); + const result = await service.findById(new ObjectId().toHexString()); - it('should return found systems with generated oauth config for oidc systems', async () => { - setup(); - if (oauthSystem.oauthConfig === undefined) { - fail('oauth system has no oauth configuration'); - } - const resultingSystems = await systemService.findByType(SystemTypeEnum.OAUTH); - - expect(resultingSystems).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: SystemTypeEnum.OAUTH, - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - oauthConfig: expect.objectContaining({ - clientId: oauthSystem.oauthConfig.clientId, - clientSecret: oauthSystem.oauthConfig.clientSecret, - idpHint: oidcSystem.oidcConfig?.idpHint, - redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, - grantType: oauthSystem.oauthConfig.grantType, - tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, - authEndpoint: oauthSystem.oauthConfig.authEndpoint, - responseType: oauthSystem.oauthConfig.responseType, - scope: oauthSystem.oauthConfig.scope, - provider: oauthSystem.oauthConfig.provider, - logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, - issuer: oauthSystem.oauthConfig.issuer, - jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, - }), - }), - ]) - ); - }); - }); - - describe('when identity management is not available', () => { - const oauthSystem = systemFactory.withOauthConfig().buildWithId(); - const oidcSystem = systemFactory.withOidcConfig().buildWithId(); - const setup = () => { - systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); - if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); - return Promise.resolve([]); - }); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); - }; - it('should filter out oidc systems', async () => { - setup(); - const result = await systemService.findByType(SystemTypeEnum.OAUTH); - expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([oauthSystem])); + expect(result).toBeNull(); }); }); }); - describe('save', () => { - describe('when creating a new system', () => { - const newSystem = systemFactory.build(); + describe('delete', () => { + describe('when the system was deleted', () => { const setup = () => { - systemRepoMock.save.mockResolvedValue(); + const system = systemFactory.build(); + + systemRepo.delete.mockResolvedValueOnce(true); + + return { + system, + }; }; - it('should save new system', async () => { - setup(); - const result = await systemService.save(newSystem); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(newSystem)); + it('should return true', async () => { + const { system } = setup(); + + const result = await service.delete(system); + + expect(result).toEqual(true); }); }); - describe('when updating an existing system', () => { - const existingSystem = systemFactory.buildWithId(); + describe('when the system was not deleted', () => { const setup = () => { - systemRepoMock.findById.mockResolvedValue(existingSystem); + const system = systemFactory.build(); + + systemRepo.delete.mockResolvedValueOnce(false); + + return { + system, + }; }; - it('should update existing system', async () => { - setup(); - const result = await systemService.save(existingSystem); - expect(systemRepoMock.findById).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(existingSystem)); + it('should return false', async () => { + const { system } = setup(); + + const result = await service.delete(system); + + expect(result).toEqual(false); }); }); }); diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index bfb6a2ec7bf..50ad7fadabf 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -1,91 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@infra/identity-management/identity-management-oauth.service'; -import { SystemRepo } from '@shared/repo'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { EntityId } from '@shared/domain/types'; +import { System } from '../domain'; +import { SystemRepo } from '../repo'; @Injectable() export class SystemService { - constructor( - private readonly systemRepo: SystemRepo, - private readonly idmOauthService: IdentityManagementOauthService - ) {} + constructor(private readonly systemRepo: SystemRepo) {} - async findById(id: EntityId): Promise { - let system = await this.systemRepo.findById(id); - [system] = await this.generateBrokerSystems([system]); - if (!system) { - throw new EntityNotFoundError(SystemEntity.name, { id }); - } - return SystemMapper.mapFromEntityToDto(system); - } + public async findById(id: EntityId): Promise { + const system: System | null = await this.systemRepo.findById(id); - async findByType(type?: SystemTypeEnum): Promise { - let systems: SystemEntity[]; - if (type && type === SystemTypeEnum.OAUTH) { - const oauthSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OAUTH); - const oidcSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OIDC); - systems = [...oauthSystems, ...oidcSystems]; - } else if (type) { - systems = await this.systemRepo.findByFilter(type); - } else { - systems = await this.systemRepo.findAll(); - } - systems = await this.generateBrokerSystems(systems); - return SystemMapper.mapFromEntitiesToDtos(systems); + return system; } - async save(systemDto: SystemDto): Promise { - let system: SystemEntity; - if (systemDto.id) { - system = await this.systemRepo.findById(systemDto.id); - system.type = systemDto.type; - system.alias = systemDto.alias; - system.displayName = systemDto.displayName; - system.oauthConfig = systemDto.oauthConfig; - system.provisioningStrategy = systemDto.provisioningStrategy; - system.provisioningUrl = systemDto.provisioningUrl; - system.url = systemDto.url; - } else { - system = new SystemEntity({ - type: systemDto.type, - alias: systemDto.alias, - displayName: systemDto.displayName, - oauthConfig: systemDto.oauthConfig, - provisioningStrategy: systemDto.provisioningStrategy, - provisioningUrl: systemDto.provisioningUrl, - url: systemDto.url, - }); - } - await this.systemRepo.save(system); - return SystemMapper.mapFromEntityToDto(system); - } + public async delete(domainObject: System): Promise { + const deleted: boolean = await this.systemRepo.delete(domainObject); - private async generateBrokerSystems(systems: SystemEntity[]): Promise<[] | SystemEntity[]> { - if (!(await this.idmOauthService.isOauthConfigAvailable())) { - return systems.filter((system) => !(system.oidcConfig && !system.oauthConfig)); - } - const brokerConfig = await this.idmOauthService.getOauthConfig(); - let generatedSystem: SystemEntity; - return systems.map((system) => { - if (system.oidcConfig && !system.oauthConfig) { - generatedSystem = new SystemEntity({ - type: SystemTypeEnum.OAUTH, - alias: system.alias, - displayName: system.displayName ? system.displayName : system.alias, - provisioningStrategy: system.provisioningStrategy, - provisioningUrl: system.provisioningUrl, - url: system.url, - }); - generatedSystem.id = system.id; - generatedSystem.oauthConfig = { ...brokerConfig }; - generatedSystem.oauthConfig.idpHint = system.oidcConfig.idpHint; - generatedSystem.oauthConfig.redirectUri += system.id; - return generatedSystem; - } - return system; - }); + return deleted; } } diff --git a/apps/server/src/modules/system/system-api.module.ts b/apps/server/src/modules/system/system-api.module.ts index e7213c1fac7..e9201f376b8 100644 --- a/apps/server/src/modules/system/system-api.module.ts +++ b/apps/server/src/modules/system/system-api.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@modules/authorization'; import { SystemController } from '@modules/system/controller/system.controller'; import { SystemUc } from '@modules/system/uc/system.uc'; +import { Module } from '@nestjs/common'; import { SystemModule } from './system.module'; @Module({ - imports: [SystemModule], + imports: [SystemModule, AuthorizationModule], providers: [SystemUc], controllers: [SystemController], }) diff --git a/apps/server/src/modules/system/system.module.ts b/apps/server/src/modules/system/system.module.ts index 37ca8d7a858..54c9d51224b 100644 --- a/apps/server/src/modules/system/system.module.ts +++ b/apps/server/src/modules/system/system.module.ts @@ -1,12 +1,13 @@ -import { Module } from '@nestjs/common'; import { IdentityManagementModule } from '@infra/identity-management/identity-management.module'; -import { SystemRepo } from '@shared/repo'; -import { SystemService } from '@modules/system/service/system.service'; +import { Module } from '@nestjs/common'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemRepo } from './repo'; +import { LegacySystemService, SystemService } from './service'; import { SystemOidcService } from './service/system-oidc.service'; @Module({ imports: [IdentityManagementModule], - providers: [SystemRepo, SystemService, SystemOidcService], - exports: [SystemService, SystemOidcService], + providers: [LegacySystemRepo, LegacySystemService, SystemOidcService, SystemService, SystemRepo], + exports: [LegacySystemService, SystemOidcService, SystemService], }) export class SystemModule {} diff --git a/apps/server/src/modules/system/uc/system.uc.spec.ts b/apps/server/src/modules/system/uc/system.uc.spec.ts index 45bd65694d9..e498718321a 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,12 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; import { SystemUc } from '@modules/system/uc/system.uc'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundError } from '@shared/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, Permission, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService } from '../../authorization'; +import { SystemMapper } from '../mapper'; +import { LegacySystemService, SystemService } from '../service'; describe('SystemUc', () => { let module: TestingModule; @@ -17,44 +20,63 @@ describe('SystemUc', () => { let system1: SystemEntity; let system2: SystemEntity; + let legacySystemService: DeepMocked; let systemService: DeepMocked; - - afterAll(async () => { - await module.close(); - }); + let authorizationService: DeepMocked; beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ providers: [ SystemUc, + { + provide: LegacySystemService, + useValue: createMock(), + }, { provide: SystemService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); + systemUc = module.get(SystemUc); + legacySystemService = module.get(LegacySystemService); systemService = module.get(SystemService); + authorizationService = module.get(AuthorizationService); }); - beforeEach(() => { - system1 = systemFactory.buildWithId(); - system2 = systemFactory.buildWithId(); - - mockSystem1 = SystemMapper.mapFromEntityToDto(system1); - mockSystem2 = SystemMapper.mapFromEntityToDto(system2); - mockSystems = [mockSystem1, mockSystem2]; + afterAll(async () => { + await module.close(); + }); - systemService.findByType.mockImplementation((type: string | undefined) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); - return Promise.resolve(mockSystems); - }); - systemService.findById.mockImplementation( - (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) - ); + afterEach(() => { + jest.clearAllMocks(); }); describe('findByFilter', () => { + beforeEach(() => { + system1 = systemEntityFactory.buildWithId(); + system2 = systemEntityFactory.buildWithId(); + + mockSystem1 = SystemMapper.mapFromEntityToDto(system1); + mockSystem2 = SystemMapper.mapFromEntityToDto(system2); + mockSystems = [mockSystem1, mockSystem2]; + + legacySystemService.findByType.mockImplementation((type: string | undefined) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); + return Promise.resolve(mockSystems); + }); + legacySystemService.findById.mockImplementation( + (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) + ); + }); + it('should return systems by default', async () => { const systems: SystemDto[] = await systemUc.findByFilter(); @@ -78,13 +100,30 @@ describe('SystemUc', () => { }); it('should return empty system list, because none exist', async () => { - systemService.findByType.mockResolvedValue([]); + legacySystemService.findByType.mockResolvedValue([]); const resultResponse = await systemUc.findByFilter(); expect(resultResponse).toHaveLength(0); }); }); describe('findById', () => { + beforeEach(() => { + system1 = systemEntityFactory.buildWithId(); + system2 = systemEntityFactory.buildWithId(); + + mockSystem1 = SystemMapper.mapFromEntityToDto(system1); + mockSystem2 = SystemMapper.mapFromEntityToDto(system2); + mockSystems = [mockSystem1, mockSystem2]; + + legacySystemService.findByType.mockImplementation((type: string | undefined) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); + return Promise.resolve(mockSystems); + }); + legacySystemService.findById.mockImplementation( + (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) + ); + }); + it('should return a system by id', async () => { const receivedSystem: SystemDto = await systemUc.findById(system1.id); @@ -102,7 +141,7 @@ describe('SystemUc', () => { type: 'ldap', }); - systemService.findById.mockResolvedValue(system); + legacySystemService.findById.mockResolvedValue(system); }; it('should reject promise, because ldap is not active', async () => { @@ -114,4 +153,97 @@ describe('SystemUc', () => { }); }); }); + + describe('delete', () => { + describe('when the system exists and the user can delete it', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build(); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + system, + }; + }; + + it('should check the permission', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, system.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + system, + AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]) + ); + }); + + it('should delete the system', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, system.id); + + expect(systemService.delete).toHaveBeenCalledWith(system); + }); + }); + + describe('when the system does not exist', () => { + const setup = () => { + systemService.findById.mockResolvedValueOnce(null); + }; + + it('should throw a not found exception', async () => { + setup(); + + await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow( + NotFoundLoggableException + ); + }); + + it('should not delete any system', async () => { + setup(); + + await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow(); + + expect(systemService.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build(); + const error = new Error(); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + system, + error, + }; + }; + + it('should throw an error', async () => { + const { user, system, error } = setup(); + + await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(error); + }); + + it('should not delete any system', async () => { + const { user, system } = setup(); + + await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(); + + expect(systemService.delete).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 00665191da7..4ced419519b 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -1,20 +1,26 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemType, SystemTypeEnum } from '@shared/domain'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, Permission, SystemEntity, SystemType, SystemTypeEnum, User } from '@shared/domain'; +import { System } from '../domain'; +import { LegacySystemService, SystemDto, SystemService } from '../service'; @Injectable() export class SystemUc { - constructor(private readonly systemService: SystemService) {} + constructor( + private readonly legacySystemService: LegacySystemService, + private readonly systemService: SystemService, + private readonly authorizationService: AuthorizationService + ) {} async findByFilter(type?: SystemType, onlyOauth = false): Promise { let systems: SystemDto[]; if (onlyOauth) { - systems = await this.systemService.findByType(SystemTypeEnum.OAUTH); + systems = await this.legacySystemService.findByType(SystemTypeEnum.OAUTH); } else { - systems = await this.systemService.findByType(type); + systems = await this.legacySystemService.findByType(type); } systems = systems.filter((system: SystemDto) => system.ldapActive !== false); @@ -23,7 +29,7 @@ export class SystemUc { } async findById(id: EntityId): Promise { - const system: SystemDto = await this.systemService.findById(id); + const system: SystemDto = await this.legacySystemService.findById(id); if (system.ldapActive === false) { throw new EntityNotFoundError(SystemEntity.name, { id }); @@ -31,4 +37,21 @@ export class SystemUc { return system; } + + async delete(userId: EntityId, systemId: EntityId): Promise { + const system: System | null = await this.systemService.findById(systemId); + + if (!system) { + throw new NotFoundLoggableException(System.name, 'id', systemId); + } + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + system, + AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]) + ); + + await this.systemService.delete(system); + } } diff --git a/apps/server/src/modules/tldraw-client/index.ts b/apps/server/src/modules/tldraw-client/index.ts new file mode 100644 index 00000000000..5b97403deca --- /dev/null +++ b/apps/server/src/modules/tldraw-client/index.ts @@ -0,0 +1 @@ +export * from './tldraw-client.module'; diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts new file mode 100644 index 00000000000..6d738d8782d --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory, setupEntities } from '@shared/testing'; +import { HttpService } from '@nestjs/axios'; +import { of } from 'rxjs'; +import { LegacyLogger } from '@src/core/logger'; +import { DrawingElementAdapterService } from './drawing-element-adapter.service'; + +describe(DrawingElementAdapterService.name, () => { + let module: TestingModule; + let service: DrawingElementAdapterService; + let httpService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DrawingElementAdapterService, + { + provide: HttpService, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DrawingElementAdapterService); + httpService = module.get(HttpService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('deleteDrawingBinData', () => { + describe('when calling the delete drawing method', () => { + const setup = () => { + httpService.delete.mockReturnValue( + of( + axiosResponseFactory.build({ + data: '', + status: HttpStatus.OK, + statusText: 'OK', + }) + ) + ); + }; + + it('should call axios delete method', async () => { + setup(); + await service.deleteDrawingBinData('test'); + expect(httpService.delete).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts new file mode 100644 index 00000000000..ff3f18abfb6 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; +import { firstValueFrom } from 'rxjs'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HttpService } from '@nestjs/axios'; + +@Injectable() +export class DrawingElementAdapterService { + constructor(private logger: LegacyLogger, private readonly httpService: HttpService) { + this.logger.setContext(DrawingElementAdapterService.name); + } + + async deleteDrawingBinData(docName: string): Promise { + await firstValueFrom( + this.httpService.delete(`${Configuration.get('TLDRAW_URI') as string}/api/v3/tldraw-document/${docName}`, { + headers: { + Accept: 'Application/json', + }, + }) + ); + } +} diff --git a/apps/server/src/modules/tldraw-client/service/index.ts b/apps/server/src/modules/tldraw-client/service/index.ts new file mode 100644 index 00000000000..10a16c9972a --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/index.ts @@ -0,0 +1 @@ +export * from './drawing-element-adapter.service'; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts new file mode 100644 index 00000000000..e015715b208 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { DrawingElementAdapterService } from './service'; + +@Module({ + imports: [LoggerModule], + providers: [DrawingElementAdapterService], + exports: [], +}) +export class TldrawClientModule {} diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts new file mode 100644 index 00000000000..a892ee6c843 --- /dev/null +++ b/apps/server/src/modules/tldraw/config.ts @@ -0,0 +1,30 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +export interface TldrawConfig { + NEST_LOG_LEVEL: string; + INCOMING_REQUEST_TIMEOUT: number; + TLDRAW_DB_COLLECTION_NAME: string; + TLDRAW_DB_FLUSH_SIZE: string; + TLDRAW_DB_MULTIPLE_COLLECTIONS: boolean; + CONNECTION_STRING: string; + FEATURE_TLDRAW_ENABLED: boolean; + TLDRAW_PING_TIMEOUT: number; + TLDRAW_GC_ENABLED: number; +} + +const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; + +const tldrawConfig = { + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, + TLDRAW_DB_COLLECTION_NAME: Configuration.get('TLDRAW__DB_COLLECTION_NAME') as string, + TLDRAW_DB_FLUSH_SIZE: Configuration.get('TLDRAW__DB_FLUSH_SIZE') as number, + TLDRAW_DB_MULTIPLE_COLLECTIONS: Configuration.get('TLDRAW__DB_MULTIPLE_COLLECTIONS') as boolean, + FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, + CONNECTION_STRING: tldrawConnectionString, + TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, + TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, +}; + +export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; +export const config = () => tldrawConfig; 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 new file mode 100644 index 00000000000..ade447b127c --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -0,0 +1,129 @@ +import { WsAdapter } from '@nestjs/platform-ws'; +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 { TestConnection } from '../../testing/test-connection'; + +describe('WebSocketController (WsAdapter)', () => { + let app: INestApplication; + let gateway: TldrawWs; + let ws: WebSocket; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + const clientMessageMock = 'test-message'; + + const getMessage = () => new TextEncoder().encode(clientMessageMock); + + beforeAll(async () => { + const testingModule = await Test.createTestingModule({ + imports: [TldrawWsTestModule], + }).compile(); + gateway = testingModule.get(TldrawWs); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when tldraw is correctly setup', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); + + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const { buffer } = getMessage(); + + return { handleConnectionSpy, buffer }; + }; + + it(`should handle connection and data transfer`, async () => { + const { handleConnectionSpy, buffer } = await setup(); + ws.send(buffer, () => {}); + + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + ws.close(); + }); + + it(`check if client will receive message`, async () => { + const { buffer } = await setup(); + ws.send(buffer, () => {}); + + gateway.server.on('connection', (client) => { + client.on('message', (payload) => { + expect(payload).toBeInstanceOf(ArrayBuffer); + }); + }); + + ws.close(); + }); + }); + + 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'); + + const { buffer } = getMessage(); + + return { + handleConnectionSpy, + ws2, + buffer, + }; + }; + + it(`should handle 2 connections at same doc and data transfer`, async () => { + const { handleConnectionSpy, ws2, buffer } = await setup(); + ws.send(buffer); + ws2.send(buffer); + + expect(handleConnectionSpy).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(2); + + ws.close(); + ws2.close(); + }); + }); + + describe('when tldraw is not correctly setup', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + + ws = await TestConnection.setupWs(wsUrl); + + return { + handleConnectionSpy, + }; + }; + + it(`should refuse connection if there is no docName`, async () => { + const { handleConnectionSpy } = await setup(); + + const { buffer } = getMessage(); + ws.send(buffer); + + expect(gateway.server).toBeDefined(); + expect(handleConnectionSpy).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + + ws.close(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts new file mode 100644 index 00000000000..0b0cf7d103b --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts new file mode 100644 index 00000000000..2528fd8c4d7 --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts @@ -0,0 +1,53 @@ +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.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts new file mode 100644 index 00000000000..3bc7137f5ec --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -0,0 +1,22 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; +import { ApiValidationError } from '@shared/common'; +import { TldrawService } from '../service/tldraw.service'; +import { TldrawDeleteParams } from './tldraw.params'; + +@ApiTags('Tldraw Document') +@Controller('tldraw-document') +export class TldrawController { + constructor(private readonly tldrawService: TldrawService) {} + + @ApiOperation({ summary: 'Delete every element of tldraw drawing by its docName.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Delete(':docName') + async deleteByDocName(@Param() urlParams: TldrawDeleteParams) { + await this.tldrawService.deleteByDocName(urlParams.docName); + } +} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.params.ts b/apps/server/src/modules/tldraw/controller/tldraw.params.ts new file mode 100644 index 00000000000..860b46332bf --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class TldrawDeleteParams { + @IsString() + @ApiProperty({ + description: 'The name of drawing that should be deleted.', + required: true, + nullable: false, + }) + docName!: string; +} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts new file mode 100644 index 00000000000..343997b2aba --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -0,0 +1,48 @@ +import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; +import { Server, WebSocket } from 'ws'; +import { ConfigService } from '@nestjs/config'; +import { TldrawConfig, SOCKET_PORT } from '../config'; +import { WsCloseCodeEnum } from '../types'; +import { TldrawWsService } from '../service'; + +@WebSocketGateway(SOCKET_PORT) +export class TldrawWs implements OnGatewayInit, OnGatewayConnection { + @WebSocketServer() + server!: Server; + + constructor( + private readonly configService: ConfigService, + private readonly tldrawWsService: TldrawWsService + ) {} + + public handleConnection(client: WebSocket, request: Request): void { + const docName = this.getDocNameFromRequest(request); + + if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { + this.tldrawWsService.setupWSConnection(client, docName); + } else { + client.close( + WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, + 'Document name is mandatory in url or Tldraw Tool is turned off.' + ); + } + } + + public afterInit(): void { + this.tldrawWsService.setPersistence({ + bindState: async (docName, ydoc) => { + await this.tldrawWsService.updateDocument(docName, ydoc); + }, + writeState: async (docName) => { + // This is called when all connections to the document are closed. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.tldrawWsService.flushDocument(docName); + }, + }); + } + + private getDocNameFromRequest(request: Request): string { + const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); + return urlStripped; + } +} 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 new file mode 100644 index 00000000000..78cf9ea9428 --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -0,0 +1,165 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo/tldraw-board.repo'; +import { TldrawWsService } from '../service'; +import { WsSharedDocDo } from './ws-shared-doc.do'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('WsSharedDocDo', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('ydoc client awareness change handler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + class MockAwareness { + on = jest.fn(); + } + const doc = new WsSharedDocDo('TEST', service); + doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; + const awarenessMetaMock = new Map(); + awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); + awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); + awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); + const awarenessStatesMock = new Map(); + awarenessStatesMock.set(1, { updating: '21' }); + awarenessStatesMock.set(2, { updating: '22' }); + awarenessStatesMock.set(3, { updating: '23' }); + doc.awareness.states = awarenessStatesMock; + doc.awareness.meta = awarenessMetaMock; + + const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); + + const mockIDs = new Set(); + const mockConns = new Map>(); + mockConns.set(ws, mockIDs); + doc.conns = mockConns; + + return { + sendSpy, + doc, + mockIDs, + mockConns, + }; + }; + + describe('when adding two clients states', () => { + it('should have two registered clients states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + const awarenessUpdate = { + added: [1, 3], + updated: [], + removed: [], + }; + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(2); + expect(mockIDs.has(1)).toBe(true); + expect(mockIDs.has(3)).toBe(true); + expect(mockIDs.has(2)).toBe(false); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when removing one of two existing clients states', () => { + it('should have one registered client state', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1, 3], + updated: [], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [], + removed: [1], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(false); + expect(mockIDs.has(3)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when updating client state', () => { + it('should not change number of states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1], + updated: [], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [1], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts new file mode 100644 index 00000000000..a7084ada0da --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts @@ -0,0 +1,88 @@ +import { Doc } from 'yjs'; +import WebSocket from 'ws'; +import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; +import { encoding } from 'lib0'; +import { TldrawWsService } from '@modules/tldraw/service'; +import { WSMessageType } from '../types/connection-enum'; + +export class WsSharedDocDo extends Doc { + public name: string; + + public conns: Map>; + + public awareness: Awareness; + + /** + * @param {string} name + * @param {TldrawWsService} tldrawService + * @param {boolean} gcEnabled + */ + constructor(name: string, private tldrawService: TldrawWsService, gcEnabled = true) { + super({ gc: gcEnabled }); + this.name = name; + this.conns = new Map(); + this.awareness = new Awareness(this); + this.awareness.setLocalState(null); + + this.awareness.on('update', this.awarenessChangeHandler); + this.on('update', (update: Uint8Array, origin, doc: WsSharedDocDo) => { + this.tldrawService.updateHandler(update, origin, doc); + }); + } + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + public awarenessChangeHandler = ( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): void => { + const changedClients = this.manageClientsConnections({ added, updated, removed }, wsConnection); + const buff = this.prepareAwarenessMessage(changedClients); + this.sendAwarenessMessage(buff); + }; + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + private manageClientsConnections( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): number[] { + const changedClients = added.concat(updated, removed); + if (wsConnection !== null) { + const connControlledIDs = this.conns.get(wsConnection); + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID); + }); + removed.forEach((clientID) => { + connControlledIDs.delete(clientID); + }); + } + } + return changedClients; + } + + /** + * @param changedClients array of changed clients + */ + private prepareAwarenessMessage(changedClients: number[]): Uint8Array { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); + const message = encoding.toUint8Array(encoder); + return message; + } + + /** + * @param {{ Uint8Array }} buff encoded message about changes + */ + private sendAwarenessMessage(buff: Uint8Array): void { + this.conns.forEach((_, c) => { + this.tldrawService.send(this, c, buff); + }); + } +} diff --git a/apps/server/src/modules/tldraw/entities/index.ts b/apps/server/src/modules/tldraw/entities/index.ts new file mode 100644 index 00000000000..2e9bb23bb67 --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/index.ts @@ -0,0 +1 @@ +export * from './tldraw-drawing.entity'; diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts new file mode 100644 index 00000000000..a85ae26319c --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts @@ -0,0 +1,29 @@ +import { setupEntities } from '@shared/testing'; +import { TldrawDrawing } from './tldraw-drawing.entity'; + +describe('tldraw entity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('when creating a tldraw doc', () => { + it('should create drawing', () => { + const tldraw = new TldrawDrawing({ + docName: 'test', + version: 'v1_tst', + value: 'bindatamock', + _id: 'test-id', + clock: 0, + action: 'update', + }); + expect(tldraw).toBeInstanceOf(TldrawDrawing); + }); + + it('should throw with empty docName', () => { + const call = () => new TldrawDrawing({ docName: '', version: 'v1_tst', value: 'bindatamock', _id: 'test-id' }); + expect(call).toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts new file mode 100644 index 00000000000..b6db76a3f2e --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts @@ -0,0 +1,46 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { BadRequestException } from '@nestjs/common'; +import { ObjectId } from '@mikro-orm/mongodb'; + +@Entity({ tableName: 'drawings' }) +export class TldrawDrawing { + constructor(props: TldrawDrawingProps) { + if (!props.docName) throw new BadRequestException('Tldraw element should have name.'); + this.docName = props.docName; + this.version = props.version; + this.value = props.value; + if (typeof props.clock === 'number') { + this.clock = props.clock; + } + if (props.action) { + this.action = props.action; + } + } + + @PrimaryKey() + _id!: ObjectId; + + @Property({ nullable: false }) + docName: string; + + @Property({ nullable: false }) + version: string; + + @Property({ nullable: false }) + value: string; + + @Property({ nullable: true }) + clock?: number; + + @Property({ nullable: true }) + action?: string; +} + +export interface TldrawDrawingProps { + _id?: string; + docName: string; + version: string; + clock?: number; + action?: string; + value: string; +} diff --git a/apps/server/src/modules/tldraw/factory/index.ts b/apps/server/src/modules/tldraw/factory/index.ts new file mode 100644 index 00000000000..7a5f39169bf --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/index.ts @@ -0,0 +1 @@ +export * from './tldraw.factory'; diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts new file mode 100644 index 00000000000..3cb63e9418b --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts @@ -0,0 +1,14 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { TldrawDrawing, TldrawDrawingProps } from '../entities'; + +export const tldrawEntityFactory = BaseFactory.define( + TldrawDrawing, + ({ sequence }) => { + return { + _id: 'test-id', + docName: 'test-name', + value: 'test-value', + version: `test-version-${sequence}`, + }; + } +); diff --git a/apps/server/src/modules/tldraw/index.ts b/apps/server/src/modules/tldraw/index.ts new file mode 100644 index 00000000000..8966e72549e --- /dev/null +++ b/apps/server/src/modules/tldraw/index.ts @@ -0,0 +1,3 @@ +export * from './tldraw.module'; +export * from './tldraw-test.module'; +export * from './tldraw-ws.module'; diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts new file mode 100644 index 00000000000..0c1ae29e62f --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -0,0 +1 @@ +export * from './tldraw-board.repo'; diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts new file mode 100644 index 00000000000..6d9b3c799bb --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts @@ -0,0 +1,221 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import { Doc } from 'yjs'; +import * as YjsUtils from '../utils/ydoc-utils'; +import { config } from '../config'; +import { TldrawBoardRepo } from './tldraw-board.repo'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawWsService } from '../service'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('TldrawBoardRepo', () => { + let app: INestApplication; + let repo: TldrawBoardRepo; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + repo = testingModule.get(TldrawBoardRepo); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should check if repo and its properties are set correctly', () => { + expect(repo).toBeDefined(); + expect(repo.mdb).toBeDefined(); + expect(repo.configService).toBeDefined(); + expect(repo.flushSize).toBeDefined(); + expect(repo.multipleCollections).toBeDefined(); + expect(repo.connectionString).toBeDefined(); + expect(repo.collectionName).toBeDefined(); + }); + + describe('updateDocument', () => { + describe('when document receives empty update', () => { + const setup = async () => { + const doc = new WsSharedDocDo('TEST2', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST2'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + + return { + doc, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should not update db with diff', async () => { + const { doc, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST2', doc); + expect(storeUpdateSpy).toHaveBeenCalledTimes(0); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when document receive update', () => { + const setup = async () => { + const clientMessageMock = 'test-message'; + const doc = new WsSharedDocDo('TEST', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + doc, + byteArray, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should update db with diff', async () => { + const { doc, byteArray, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST', doc); + doc.emit('update', [byteArray, undefined, doc]); + expect(storeUpdateSpy).toHaveBeenCalled(); + expect(storeUpdateSpy).toHaveBeenCalledTimes(1); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + }); + + describe('getYDocFromMdb', () => { + describe('when taking doc data from db', () => { + const setup = () => { + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + + return { + storeGetYDocSpy, + }; + }; + + it('should return ydoc', async () => { + const { storeGetYDocSpy } = setup(); + expect(await repo.getYDocFromMdb('test')).toBeInstanceOf(Doc); + + storeGetYDocSpy.mockRestore(); + }); + }); + }); + + describe('updateStoredDocWithDiff', () => { + describe('when the difference between update and current drawing is more than 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 1); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockResolvedValueOnce(Promise.resolve(1)); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + + describe('when the difference between update and current drawing is 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 0); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate'); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should not call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).not.toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method on mdbPersistence', async () => { + const { flushDocumentSpy } = setup(); + await repo.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.mockRestore(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts new file mode 100644 index 00000000000..ce3a124f7f0 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { MongodbPersistence } from 'y-mongodb-provider'; +import { ConfigService } from '@nestjs/config'; +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; +import { TldrawConfig } from '../config'; +import { calculateDiff } from '../utils'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +@Injectable() +export class TldrawBoardRepo { + public connectionString: string; + + public collectionName: string; + + public flushSize: number; + + public multipleCollections: boolean; + + public mdb: MongodbPersistence; + + constructor(public readonly configService: ConfigService) { + this.connectionString = this.configService.get('CONNECTION_STRING'); + this.collectionName = this.configService.get('TLDRAW_DB_COLLECTION_NAME') ?? 'drawings'; + this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE') ?? 400; + this.multipleCollections = this.configService.get('TLDRAW_DB_MULTIPLE_COLLECTIONS'); + + // 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, + }); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line consistent-return + public async getYDocFromMdb(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const yDoc = await this.mdb.getYDoc(docName); + if (yDoc instanceof Doc) { + return yDoc; + } + } + + public updateStoredDocWithDiff(docName: string, diff: Uint8Array): void { + const calc = calculateDiff(diff); + if (calc > 0) { + void this.mdb.storeUpdate(docName, diff); + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + const persistedYdoc = await this.getYDocFromMdb(docName); + const persistedStateVector = encodeStateVector(persistedYdoc); + const diff = encodeStateAsUpdate(ydoc, persistedStateVector); + this.updateStoredDocWithDiff(docName, diff); + + applyUpdate(ydoc, encodeStateAsUpdate(persistedYdoc)); + + ydoc.on('update', (update: Uint8Array) => { + void this.mdb.storeUpdate(docName, update); + }); + + persistedYdoc.destroy(); + } + + public async flushDocument(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.mdb.flushDocument(docName); + } +} diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts new file mode 100644 index 00000000000..9e6f5eabb14 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -0,0 +1,92 @@ +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 { TldrawRepo } from './tldraw.repo'; + +describe(TldrawRepo.name, () => { + let module: TestingModule; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + providers: [TldrawRepo], + }).compile(); + repo = module.get(TldrawRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('create', () => { + describe('when called', () => { + it('should create new drawing node', async () => { + const drawing = tldrawEntityFactory.build(); + + await repo.create(drawing); + em.clear(); + + const result = await em.find(TldrawDrawing, {}); + expect(result[0]._id).toEqual(drawing._id); + }); + + it('should flush the changes', async () => { + const drawing = tldrawEntityFactory.build(); + jest.spyOn(em, 'flush'); + + await repo.create(drawing); + + expect(em.flush).toHaveBeenCalled(); + }); + }); + }); + + describe('findByDocName', () => { + describe('when finding by docName', () => { + const setup = async () => { + const drawing = tldrawEntityFactory.build(); + await em.persistAndFlush(drawing); + em.clear(); + + return { drawing }; + }; + + it('should return the object', async () => { + const { drawing } = await setup(); + const result = await repo.findByDocName(drawing.docName); + expect(result[0].docName).toEqual(drawing.docName); + 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); + }); + }); + }); + + describe('delete', () => { + describe('when finding by docName and deleting all records', () => { + it('should delete all records', async () => { + const drawing = tldrawEntityFactory.build(); + await repo.create(drawing); + + const results = await repo.findByDocName(drawing.docName); + await repo.delete(results); + + const emptyResults = await repo.findByDocName(drawing.docName); + expect(emptyResults.length).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts new file mode 100644 index 00000000000..d826b2876ff --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -0,0 +1,20 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { TldrawDrawing } from '../entities'; + +@Injectable() +export class TldrawRepo { + constructor(private readonly _em: EntityManager) {} + + async create(entity: TldrawDrawing): Promise { + await this._em.persistAndFlush(entity); + } + + async findByDocName(docName: string): Promise { + return this._em.find(TldrawDrawing, { docName }); + } + + async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { + await this._em.removeAndFlush(entity); + } +} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts new file mode 100644 index 00000000000..a056b2ece10 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws.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 new file mode 100644 index 00000000000..cc3a317ec3c --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { TldrawDrawing } from '../entities'; +import { tldrawEntityFactory } from '../factory'; +import { TldrawRepo } from '../repo/tldraw.repo'; +import { TldrawService } from './tldraw.service'; + +describe(TldrawService.name, () => { + let module: TestingModule; + let service: TldrawService; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + providers: [TldrawService, TldrawRepo], + }).compile(); + + repo = module.get(TldrawRepo); + service = module.get(TldrawService); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + jest.resetAllMocks(); + }); + + describe('delete', () => { + describe('when deleting all collection connected to one drawing', () => { + it('should remove all collections giving drawing name', async () => { + const drawing = tldrawEntityFactory.build(); + + await repo.create(drawing); + const result = await repo.findByDocName(drawing.docName); + + expect(result.length).toEqual(1); + + await service.deleteByDocName(drawing.docName); + const emptyResult = await repo.findByDocName(drawing.docName); + + expect(emptyResult.length).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.ts b/apps/server/src/modules/tldraw/service/tldraw.service.ts new file mode 100644 index 00000000000..4e0aa3db8db --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { TldrawRepo } from '../repo/tldraw.repo'; + +@Injectable() +export class TldrawService { + constructor(private readonly tldrawRepo: TldrawRepo) {} + + async deleteByDocName(docName: string): Promise { + const drawings = await this.tldrawRepo.findByDocName(docName); + await this.tldrawRepo.delete(drawings); + } +} 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 new file mode 100644 index 00000000000..ddd186fed0a --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -0,0 +1,449 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { TextEncoder } from 'util'; +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 { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '.'; +import { TestConnection } from '../testing/test-connection'; + +jest.mock('y-protocols/awareness', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/awareness'), + }; + return moduleMock; +}); +jest.mock('y-protocols/sync', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/sync'), + }; + return moduleMock; +}); + +describe('TldrawWSService', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [TldrawWs, TldrawBoardRepo, TldrawWsService], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + const createMessage = (values: number[]) => { + const encoder = encoding.createEncoder(); + values.forEach((val) => { + encoding.writeVarUint(encoder, val); + }); + encoding.writeVarUint(encoder, 0); + encoding.writeVarUint(encoder, 1); + const msg = encoding.toUint8Array(encoder); + return { + msg, + }; + }; + + it('should chcek if service properties are set correctly', () => { + expect(service).toBeDefined(); + expect(service.pingTimeout).toBeDefined(); + expect(service.persistence).toBeDefined(); + }); + + describe('send', () => { + describe('when client is not connected to WS', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + closeConSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should throw error for send message', async () => { + const { closeConSpy, sendSpy, doc, byteArray } = await setup(); + + service.send(doc, ws, byteArray); + + expect(sendSpy).toThrow(); + expect(sendSpy).toHaveBeenCalledWith(doc, ws, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state different than 0 or 1', () => { + const setup = () => { + const clientMessageMock = 'test-message'; + const closeConSpy = jest.spyOn(service, 'closeConn'); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(3); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + closeConSpy, + sendSpy, + doc, + socketMock, + byteArray, + }; + }; + + it('should close connection', () => { + const { closeConSpy, sendSpy, doc, socketMock, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(closeConSpy).toHaveBeenCalled(); + + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state 0', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(0); + doc.conns.set(socketMock, new Set()); + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 2); + const updateByteArray = new TextEncoder().encode(clientMessageMock); + encoding.writeVarUint8Array(encoder, updateByteArray); + const msg = encoding.toUint8Array(encoder); + return { + sendSpy, + doc, + msg, + }; + }; + + it('should call send in updateHandler', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.updateHandler(msg, {}, doc); + + expect(sendSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when received message of specific type', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const sendSpy = jest.spyOn(service, 'send'); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); + const syncProtocolUpdateSpy = jest + .spyOn(SyncProtocols, 'readSyncMessage') + .mockImplementationOnce((dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const doc = new WsSharedDocDo('TEST', service); + const { msg } = createMessage(messageValues); + + return { + sendSpy, + applyAwarenessUpdateSpy, + syncProtocolUpdateSpy, + doc, + msg, + }; + }; + + it('should call send method when received message of type SYNC', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([0, 1]); + + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(1); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + + it('should not call send method when received message of type AWARENESS', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([1, 1, 0]); + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(1); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + + it('should do nothing when received message unknown type', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([2]); + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + }); + + describe('when error is thrown during receiving message', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send'); + jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { + throw new Error('error'); + }); + const doc = new WsSharedDocDo('TEST', service); + const { msg } = createMessage([0]); + + return { + sendSpy, + doc, + msg, + }; + }; + + it('should not call send method', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when awareness states (clients) size is greater then one', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const doc = new WsSharedDocDo('TEST', service); + doc.awareness.states = new Map(); + doc.awareness.states.set(1, ['test1']); + doc.awareness.states.set(2, ['test2']); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const getYDocSpy = jest.spyOn(service, 'getYDoc').mockImplementationOnce(() => doc); + const { msg } = createMessage([0]); + jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockImplementationOnce(() => msg); + + return { + messageHandlerSpy, + sendSpy, + getYDocSpy, + }; + }; + + it('should send to every client', async () => { + const { messageHandlerSpy, sendSpy, getYDocSpy } = await setup(); + + service.setupWSConnection(ws); + + expect(sendSpy).toHaveBeenCalledTimes(2); + + ws.close(); + messageHandlerSpy.mockRestore(); + sendSpy.mockRestore(); + getYDocSpy.mockRestore(); + }); + }); + }); + + describe('closeConn', () => { + describe('when trying to close already closed connection', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + jest.spyOn(ws, 'close').mockImplementationOnce(() => { + throw new Error('some error'); + }); + }; + + it('should throw error', async () => { + await setup(); + try { + const doc = TldrawWsFactory.createWsSharedDocDo(); + service.closeConn(doc, ws); + } catch (err) { + expect(err).toBeDefined(); + } + + ws.close(); + }); + }); + + describe('when ping failed', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const closeConnSpy = jest.spyOn(service, 'closeConn'); + jest.spyOn(ws, 'ping').mockImplementationOnce(() => { + throw new Error('error'); + }); + + return { + messageHandlerSpy, + closeConnSpy, + }; + }; + + it('should close connection', async () => { + const { messageHandlerSpy, closeConnSpy } = await setup(); + + service.setupWSConnection(ws); + + await delay(10); + + expect(closeConnSpy).toHaveBeenCalled(); + + ws.close(); + messageHandlerSpy.mockRestore(); + closeConnSpy.mockRestore(); + }); + }); + }); + + describe('messageHandler', () => { + describe('when message is received', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler'); + const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const { msg } = createMessage(messageValues); + + return { + messageHandlerSpy, + msg, + readSyncMessageSpy, + }; + }; + + it('should handle message', async () => { + const { messageHandlerSpy, msg, readSyncMessageSpy } = await setup([0, 1]); + + service.setupWSConnection(ws); + ws.emit('message', msg); + + expect(messageHandlerSpy).toHaveBeenCalledTimes(1); + + ws.close(); + messageHandlerSpy.mockRestore(); + readSyncMessageSpy.mockRestore(); + }); + }); + }); + + describe('getYDoc', () => { + describe('when getting yDoc by name', () => { + it('should assign to service.doc and return instance', () => { + const docName = 'get-test'; + const doc = service.getYDoc(docName); + expect(doc).toBeInstanceOf(WsSharedDocDo); + expect(service.docs.get(docName)).not.toBeUndefined(); + }); + }); + }); + + describe('updateDocument', () => { + const setup = () => { + const updateDocumentSpy = jest.spyOn(service, 'updateDocument').mockImplementation(() => Promise.resolve()); + + return { updateDocumentSpy }; + }; + + it('should call update method', async () => { + const { updateDocumentSpy } = setup(); + await service.updateDocument('test', TldrawWsFactory.createWsSharedDocDo()); + + expect(updateDocumentSpy).toHaveBeenCalled(); + + updateDocumentSpy.mockRestore(); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(service, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method', async () => { + const { flushDocumentSpy } = setup(); + await service.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.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 new file mode 100644 index 00000000000..660f5258fa8 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -0,0 +1,209 @@ +import { Injectable } 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 { Persitence, WSConnectionState, WSMessageType } from '../types'; +import { TldrawConfig } from '../config'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawBoardRepo } from '../repo'; + +@Injectable() +export class TldrawWsService { + public pingTimeout: number; + + public persistence: Persitence | null = null; + + public docs = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly tldrawBoardRepo: TldrawBoardRepo + ) { + this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); + } + + public setPersistence(persistence_: Persitence): void { + this.persistence = persistence_; + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} ws + */ + public closeConn(doc: WsSharedDocDo, ws: WebSocket): void { + if (doc.conns.has(ws)) { + const controlledIds = doc.conns.get(ws) as Set; + doc.conns.delete(ws); + removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); + if (doc.conns.size === 0 && this.persistence !== null) { + // if persisted, we store state and destroy ydocument + this.persistence + .writeState(doc.name, doc) + .then(() => { + doc.destroy(); + return null; + }) + .catch(() => {}); + this.docs.delete(doc.name); + } + } + + try { + ws.close(); + } catch (err) { + throw new Error('Cannot close the connection. It is possible that connection is already closed.'); + } + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} conn + * @param {Uint8Array} message + */ + public send(doc: WsSharedDocDo, conn: WebSocket, message: Uint8Array): void { + if (conn.readyState !== WSConnectionState.CONNECTING && conn.readyState !== WSConnectionState.OPEN) { + this.closeConn(doc, conn); + } + try { + conn.send(message, (err: Error | undefined) => { + if (err != null) { + this.closeConn(doc, conn); + } + }); + } catch (e) { + this.closeConn(doc, conn); + } + } + + /** + * @param {Uint8Array} update + * @param {any} origin + * @param {WsSharedDocDo} doc + */ + public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => { + this.send(doc, conn, message); + }); + } + + /** + * Gets a Y.Doc by name, whether in memory or on disk + * + * @param {string} docName - the name of the Y.Doc to find or create + * @param {boolean} gc - whether to allow gc on the doc (applies only when created) + * @return {WsSharedDocDo} + */ + getYDoc(docName: string, gc = true): WsSharedDocDo { + return map.setIfUndefined(this.docs, docName, () => { + const doc = new WsSharedDocDo(docName, this, gc); + if (this.persistence !== null) { + this.persistence.bindState(docName, doc).catch(() => {}); + } + this.docs.set(docName, doc); + return doc; + }); + } + + public messageHandler(conn: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case WSMessageType.SYNC: + encoding.writeVarUint(encoder, WSMessageType.SYNC); + readSyncMessage(decoder, encoder, doc, conn); + + // If the `encoder` only contains the type of reply message and no + // message, there is no need to send the message. When `encoder` only + // contains the type of reply, its length is 1. + if (encoding.length(encoder) > 1) { + this.send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + case WSMessageType.AWARENESS: { + applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + break; + } + default: + break; + } + } catch (err) { + doc.emit('error', [err]); + } + } + + /** + * @param {WebSocket} ws + * @param {string} docName + */ + public setupWSConnection(ws: WebSocket, docName = 'GLOBAL'): void { + ws.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet + const doc = this.getYDoc(docName, true); + doc.conns.set(ws, new Set()); + + // listen and reply to events + ws.on('message', (message: ArrayBufferLike) => { + this.messageHandler(ws, doc, new Uint8Array(message)); + }); + + // Check if connection is still alive + let pongReceived = true; + const pingInterval = setInterval(() => { + const hasConn = doc.conns.has(ws); + + if (pongReceived) { + if (!hasConn) return; + pongReceived = false; + + try { + ws.ping(); + } catch (e) { + this.closeConn(doc, ws); + clearInterval(pingInterval); + } + return; + } + + if (hasConn) { + this.closeConn(doc, ws); + } + + clearInterval(pingInterval); + }, this.pingTimeout); + ws.on('close', () => { + this.closeConn(doc, ws); + clearInterval(pingInterval); + }); + ws.on('pong', () => { + pongReceived = true; + }); + { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeSyncStep1(encoder, doc); + this.send(doc, ws, encoding.toUint8Array(encoder)); + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); + this.send(doc, ws, encoding.toUint8Array(encoder)); + } + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + await this.tldrawBoardRepo.updateDocument(docName, ydoc); + } + + public async flushDocument(docName: string): Promise { + await this.tldrawBoardRepo.flushDocument(docName); + } +} diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts new file mode 100644 index 00000000000..638c219ea18 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -0,0 +1,22 @@ +import WebSocket from 'ws'; + +export class TestConnection { + public static getWsUrl = (gatewayPort: number): string => { + const wsUrl = `ws://localhost:${gatewayPort}`; + return wsUrl; + }; + + public static setupWs = async (wsUrl: string, docName?: string): Promise => { + let ws: WebSocket; + if (docName) { + ws = new WebSocket(`${wsUrl}/${docName}`); + } else { + ws = new WebSocket(`${wsUrl}`); + } + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + return ws; + }; +} diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts new file mode 100644 index 00000000000..19c38171b88 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -0,0 +1,36 @@ +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'; +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'; + +const imports = [ + TldrawWsModule, + MongoMemoryDatabaseModule.forRoot({ entities: [User, Course] }), + AuthenticationApiModule, + AuthorizationModule, + AuthenticationModule, + CoreModule, + LoggerModule, +]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +@Module({ + imports, + providers, +}) +export class TldrawTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: TldrawTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + 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 new file mode 100644 index 00000000000..6e3c5a58479 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +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]; +@Module({ + imports, + providers, +}) +export class TldrawWsTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: TldrawWsTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + providers, + }; + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts new file mode 100644 index 00000000000..98e91b5b3e6 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; +import { TldrawWs } from './controller'; +import { config } from './config'; + +@Module({ + imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo], +}) +export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/tldraw.module.ts b/apps/server/src/modules/tldraw/tldraw.module.ts new file mode 100644 index 00000000000..fa5ebf59d02 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw.module.ts @@ -0,0 +1,43 @@ +import { Module, NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { AuthorizationModule } from '@modules/authorization'; +import { TldrawDrawing } from './entities'; +import { config } from './config'; +import { TldrawService } from './service/tldraw.service'; +import { TldrawBoardRepo } from './repo'; +import { TldrawController } from './controller/tldraw.controller'; +import { TldrawRepo } from './repo/tldraw.repo'; + +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; + +@Module({ + imports: [ + AuthorizationModule, + AuthenticationModule, + CoreModule, + RabbitMQWrapperTestModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [Logger, TldrawService, TldrawBoardRepo, TldrawRepo], + controllers: [TldrawController], +}) +export class TldrawModule {} diff --git a/apps/server/src/modules/tldraw/types/connection-enum.ts b/apps/server/src/modules/tldraw/types/connection-enum.ts new file mode 100644 index 00000000000..6a9a4692e03 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/connection-enum.ts @@ -0,0 +1,9 @@ +export enum WSConnectionState { + CONNECTING = 0, + OPEN = 1, +} + +export enum WSMessageType { + SYNC = 0, + AWARENESS = 1, +} diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts new file mode 100644 index 00000000000..0579e4b8c79 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -0,0 +1,3 @@ +export * from './connection-enum'; +export * from './ws-close-code-enum'; +export * from './persistence-type'; diff --git a/apps/server/src/modules/tldraw/types/persistence-type.ts b/apps/server/src/modules/tldraw/types/persistence-type.ts new file mode 100644 index 00000000000..ee8d4510275 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/persistence-type.ts @@ -0,0 +1,6 @@ +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +export type Persitence = { + bindState: (docName: string, ydoc: WsSharedDocDo) => Promise; + writeState: (docName: string, ydoc: WsSharedDocDo) => Promise; +}; 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 new file mode 100644 index 00000000000..274fa99a6ae --- /dev/null +++ b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts @@ -0,0 +1,3 @@ +export enum WsCloseCodeEnum { + WS_CLIENT_BAD_REQUEST_CODE = 4400, +} diff --git a/apps/server/src/modules/tldraw/utils/index.ts b/apps/server/src/modules/tldraw/utils/index.ts new file mode 100644 index 00000000000..a51b9059bc1 --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/index.ts @@ -0,0 +1 @@ +export * from './ydoc-utils'; diff --git a/apps/server/src/modules/tldraw/utils/ydoc-utils.ts b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts new file mode 100644 index 00000000000..6d0817ecc9d --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts @@ -0,0 +1,2 @@ +export const calculateDiff = (diff: Uint8Array): number => + diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0); diff --git a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts new file mode 100644 index 00000000000..7e34d995267 --- /dev/null +++ b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ContextExternalToolCountPerContextResponse { + @ApiProperty() + course: number; + + @ApiProperty() + boardElement: number; + + constructor(props: ContextExternalToolCountPerContextResponse) { + this.course = props.course; + this.boardElement = props.boardElement; + } +} diff --git a/apps/server/src/modules/tool/common/controller/dto/index.ts b/apps/server/src/modules/tool/common/controller/dto/index.ts new file mode 100644 index 00000000000..87fa450d468 --- /dev/null +++ b/apps/server/src/modules/tool/common/controller/dto/index.ts @@ -0,0 +1 @@ +export { ContextExternalToolCountPerContextResponse } from './context-external-tool-count-per-context.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts index d38b48cc503..745d0208a39 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts @@ -1,17 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ContextExternalToolType } from '../../../../context-external-tool/entity'; +import { ContextExternalToolCountPerContextResponse } from '../../../../common/controller/dto'; export class ExternalToolMetadataResponse { @ApiProperty() schoolExternalToolCount: number; - @ApiProperty({ - type: 'object', - properties: Object.fromEntries( - Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) - ), - }) - contextExternalToolCountPerContext: Record; + @ApiProperty() + contextExternalToolCountPerContext: ContextExternalToolCountPerContextResponse; constructor(externalToolMetadataResponse: ExternalToolMetadataResponse) { this.schoolExternalToolCount = externalToolMetadataResponse.schoolExternalToolCount; diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts index 04492680ff7..5fa4b0f3f7c 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts @@ -1,7 +1,9 @@ +import { ContextExternalToolType } from '../../context-external-tool/entity'; + export class ExternalToolMetadata { schoolExternalToolCount: number; - contextExternalToolCountPerContext: Record; + contextExternalToolCountPerContext: Record; constructor(externalToolMetadata: ExternalToolMetadata) { this.schoolExternalToolCount = externalToolMetadata.schoolExternalToolCount; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts index b3d6555f898..f5c257b5430 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts @@ -1,3 +1,4 @@ +import { ContextExternalToolCountPerContextResponse } from '../../common/controller/dto'; import { ExternalToolMetadataResponse } from '../controller/dto'; import { ExternalToolMetadata } from '../domain'; @@ -5,7 +6,9 @@ export class ExternalToolMetadataMapper { static mapToExternalToolMetadataResponse(externalToolMetadata: ExternalToolMetadata): ExternalToolMetadataResponse { const externalToolMetadataResponse: ExternalToolMetadataResponse = new ExternalToolMetadataResponse({ schoolExternalToolCount: externalToolMetadata.schoolExternalToolCount, - contextExternalToolCountPerContext: externalToolMetadata.contextExternalToolCountPerContext, + contextExternalToolCountPerContext: new ContextExternalToolCountPerContextResponse( + externalToolMetadata.contextExternalToolCountPerContext + ), }); return externalToolMetadataResponse; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts index 9476e738a87..ef82629e87d 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { ToolContextType } from '../../common/enum'; +import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; import { ContextExternalToolType } from '../../context-external-tool/entity'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalToolMetadata } from '../domain'; -import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; @Injectable() export class ExternalToolMetadataService { @@ -40,11 +40,11 @@ export class ExternalToolMetadataService { ); } - const externaltoolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ schoolExternalToolCount: schoolExternalTools.length, contextExternalToolCountPerContext: contextExternalToolCount, }); - return externaltoolMetadata; + return externalToolMetadata; } } diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index ff209ac301d..35a20ca8997 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -7,10 +7,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, Permission, Role, SortOrder, User } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { - externalToolFactory, - oauth2ToolConfigFactory, -} from '@shared/testing/factory/domainobject/tool/external-tool.factory'; +import { externalToolFactory, oauth2ToolConfigFactory } from '@shared/testing/factory'; import { ExternalToolSearchQuery } from '../../common/interface'; import { ExternalTool, ExternalToolMetadata, Oauth2ToolConfig } from '../domain'; import { @@ -550,7 +547,7 @@ describe('ExternalToolUc', () => { const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ schoolExternalToolCount: 2, - contextExternalToolCountPerContext: { course: 3, 'board-element': 3 }, + contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, }); externalToolMetadataService.getMetadata.mockResolvedValue(externalToolMetadata); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts index db4806d23ec..c61b24df3c9 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts @@ -1,14 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ContextExternalToolType } from '../../../context-external-tool/entity'; +import { ContextExternalToolCountPerContextResponse } from '../../../common/controller/dto'; export class SchoolExternalToolMetadataResponse { - @ApiProperty({ - type: 'object', - properties: Object.fromEntries( - Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) - ), - }) - contextExternalToolCountPerContext: Record; + @ApiProperty() + contextExternalToolCountPerContext: ContextExternalToolCountPerContextResponse; constructor(schoolExternalToolMetadataResponse: SchoolExternalToolMetadataResponse) { this.contextExternalToolCountPerContext = schoolExternalToolMetadataResponse.contextExternalToolCountPerContext; diff --git a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts index 4cccdfe11a1..741857352d3 100644 --- a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts @@ -1,5 +1,7 @@ +import { ContextExternalToolType } from '../../context-external-tool/entity'; + export class SchoolExternalToolMetadata { - contextExternalToolCountPerContext: Record; + contextExternalToolCountPerContext: Record; constructor(schoolExternalToolMetadata: SchoolExternalToolMetadata) { this.contextExternalToolCountPerContext = schoolExternalToolMetadata.contextExternalToolCountPerContext; diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts index 1e42f22ebf2..acc58dd6ef8 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts @@ -1,3 +1,4 @@ +import { ContextExternalToolCountPerContextResponse } from '../../common/controller/dto'; import { SchoolExternalToolMetadataResponse } from '../controller/dto'; import { SchoolExternalToolMetadata } from '../domain'; @@ -6,7 +7,9 @@ export class SchoolExternalToolMetadataMapper { schoolExternalToolMetadata: SchoolExternalToolMetadata ): SchoolExternalToolMetadataResponse { const externalToolMetadataResponse: SchoolExternalToolMetadataResponse = new SchoolExternalToolMetadataResponse({ - contextExternalToolCountPerContext: schoolExternalToolMetadata.contextExternalToolCountPerContext, + contextExternalToolCountPerContext: new ContextExternalToolCountPerContextResponse( + schoolExternalToolMetadata.contextExternalToolCountPerContext + ), }); return externalToolMetadataResponse; diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts index fa8fd4fc926..53061147aca 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts @@ -28,10 +28,10 @@ export class SchoolExternalToolMetadataService { }) ); - const schoolExternaltoolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ contextExternalToolCountPerContext: contextExternalToolCount, }); - return schoolExternaltoolMetadata; + return schoolExternalToolMetadata; } } diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts index 51bea6b1224..5ee7db64ce0 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts @@ -39,7 +39,7 @@ import { mapUserToCurrentUser, roleFactory, schoolFactory, - systemFactory, + systemEntityFactory, userFactory, } from '@shared/testing'; import { Request } from 'express'; @@ -51,7 +51,7 @@ describe('ImportUser Controller (API)', () => { let currentUser: ICurrentUser; const authenticatedUser = async (permissions: Permission[] = [], features: SchoolFeatures[] = []) => { - const system = systemFactory.buildWithId(); // TODO no id? + const system = systemEntityFactory.buildWithId(); // TODO no id? const school = schoolFactory.build({ officialSchoolNumber: 'foo', features }); const roles = [roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions })]; await em.persistAndFlush([school, system, ...roles]); diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts index c93c7bc58a7..ab94fc43143 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; import { AccountService } from '@modules/account/services/account.service'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { LoggerModule } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { UserImportUc } from '../uc/user-import.uc'; import { ImportUserController } from './import-user.controller'; @@ -38,7 +38,7 @@ describe('ImportUserController', () => { useValue: {}, }, { - provide: SystemRepo, + provide: LegacySystemRepo, useValue: {}, }, { diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index ff4aa4c266c..b68cff8074f 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -1,6 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountService } from '@modules/account/services/account.service'; +import { AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -16,14 +20,10 @@ import { SystemEntity, User, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { federalStateFactory, importUserFactory, schoolFactory, userFactory } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { systemEntityFactory } from '@shared/testing/factory/systemEntityFactory'; import { LoggerModule } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -38,7 +38,7 @@ describe('[ImportUserModule]', () => { let accountService: DeepMocked; let importUserRepo: DeepMocked; let schoolService: DeepMocked; - let systemRepo: DeepMocked; + let systemRepo: DeepMocked; let userRepo: DeepMocked; let authorizationService: DeepMocked; let configurationSpy: jest.SpyInstance; @@ -65,8 +65,8 @@ describe('[ImportUserModule]', () => { useValue: createMock(), }, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, { provide: UserRepo, @@ -82,7 +82,7 @@ describe('[ImportUserModule]', () => { accountService = module.get(AccountService); importUserRepo = module.get(ImportUserRepo); schoolService = module.get(LegacySchoolService); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); userRepo = module.get(UserRepo); authorizationService = module.get(AuthorizationService); }); @@ -472,7 +472,7 @@ describe('[ImportUserModule]', () => { let userRepoFlushSpy: jest.SpyInstance; let accountServiceFindByUserIdSpy: jest.SpyInstance; beforeEach(() => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId({ systems: [system] }); school.externalId = 'foo'; school.inMaintenanceSince = new Date(); @@ -605,7 +605,7 @@ describe('[ImportUserModule]', () => { const currentDate = new Date('2022-03-10T00:00:00.000Z'); let dateSpy: jest.SpyInstance; beforeEach(() => { - system = systemFactory.buildWithId({ ldapConfig: {} }); + system = systemEntityFactory.buildWithId({ ldapConfig: {} }); school = schoolFactory.buildWithId(); school.officialSchoolNumber = 'foo'; currentUser = userFactory.buildWithId({ school }); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index a9290bb5e42..4e3b14b6734 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -21,7 +21,7 @@ import { SystemEntity, User, } from '@shared/domain'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; import { AccountSaveDto } from '../../account/services/dto'; import { @@ -50,7 +50,7 @@ export class UserImportUc { private readonly importUserRepo: ImportUserRepo, private readonly authorizationService: AuthorizationService, private readonly schoolService: LegacySchoolService, - private readonly systemRepo: SystemRepo, + private readonly systemRepo: LegacySystemRepo, private readonly userRepo: UserRepo, private readonly logger: Logger ) { diff --git a/apps/server/src/modules/user-import/user-import.module.ts b/apps/server/src/modules/user-import/user-import.module.ts index 2cf4d94704e..48fa730c61f 100644 --- a/apps/server/src/modules/user-import/user-import.module.ts +++ b/apps/server/src/modules/user-import/user-import.module.ts @@ -1,7 +1,7 @@ +import { LegacySchoolModule } from '@modules/legacy-school'; import { Module } from '@nestjs/common'; -import { ImportUserRepo, LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { LegacySchoolModule } from '@modules/legacy-school'; import { AccountModule } from '../account'; import { AuthorizationModule } from '../authorization'; import { ImportUserController } from './controller/import-user.controller'; @@ -10,7 +10,7 @@ import { UserImportUc } from './uc/user-import.uc'; @Module({ imports: [LoggerModule, AccountModule, LegacySchoolModule, AuthorizationModule], controllers: [ImportUserController], - providers: [UserImportUc, ImportUserRepo, LegacySchoolRepo, SystemRepo, UserRepo], + providers: [UserImportUc, ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo], exports: [], }) /** diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index 07bdbc094f3..9d99c3e3149 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -12,7 +12,7 @@ import { cleanupCollections, JwtTestFactory, schoolFactory, - systemFactory, + systemEntityFactory, TestApiClient, UserAndAccountTestFactory, userFactory, @@ -71,8 +71,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when data is given', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -136,8 +136,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when a user login migration exists', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -232,8 +232,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /start', () => { describe('when current user start the migration successfully', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -316,8 +316,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration already started', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -355,8 +355,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration already closed', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -395,8 +395,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when official school number is not set', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -463,7 +463,7 @@ describe('UserLoginMigrationController (API)', () => { describe('when providing a code and being eligible to migrate', () => { const setup = async () => { - const targetSystem: SystemEntity = systemFactory + const targetSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); @@ -473,7 +473,7 @@ describe('UserLoginMigrationController (API)', () => { query.systemId = targetSystem.id; query.redirectUri = 'redirectUri'; - const sourceSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; @@ -531,7 +531,7 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration failed, because of schoolnumbers mismatch', () => { const setup = async () => { - const targetSystem: SystemEntity = systemFactory + const targetSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); @@ -540,7 +540,7 @@ describe('UserLoginMigrationController (API)', () => { query.systemId = targetSystem.id; query.redirectUri = 'redirectUri'; - const sourceSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; @@ -617,8 +617,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /restart', () => { describe('when current User restart the migration successfully', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -716,8 +716,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is already started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -754,8 +754,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is finally finished', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -797,8 +797,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[PUT] /mandatory', () => { describe('when migration is set from optional to mandatory', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -849,8 +849,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is set from mandatory to optional', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -901,8 +901,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is not started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -931,8 +931,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is closed', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -979,8 +979,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1021,8 +1021,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /close', () => { describe('when the user login migration is running', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1097,8 +1097,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is not started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1140,8 +1140,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is already closed', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1206,8 +1206,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is finished', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1254,8 +1254,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1293,8 +1293,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when no user has migrate', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts index 0edaf9d1a38..35e9e02b473 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemService } from '@modules/system'; +import { LegacySystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; import { InternalServerErrorException } from '@nestjs/common'; @@ -22,7 +22,7 @@ describe(UserLoginMigrationService.name, () => { let userService: DeepMocked; let schoolService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let userLoginMigrationRepo: DeepMocked; const mockedDate: Date = new Date('2023-05-02'); @@ -46,8 +46,8 @@ describe(UserLoginMigrationService.name, () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: UserLoginMigrationRepo, @@ -59,7 +59,7 @@ describe(UserLoginMigrationService.name, () => { service = module.get(UserLoginMigrationService); userService = module.get(UserService); schoolService = module.get(LegacySchoolService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); userLoginMigrationRepo = module.get(UserLoginMigrationRepo); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts index 459b163f119..f9ee07a7fde 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts @@ -1,6 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId, LegacySchoolDo, SchoolFeatures, SystemTypeEnum, UserDO, UserLoginMigrationDO } from '@shared/domain'; @@ -16,7 +16,7 @@ export class UserLoginMigrationService { private readonly userService: UserService, private readonly userLoginMigrationRepo: UserLoginMigrationRepo, private readonly schoolService: LegacySchoolService, - private readonly systemService: SystemService + private readonly systemService: LegacySystemService ) {} public async startMigration(schoolId: string): Promise { diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts index 4179505d009..59271414f1d 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -3,12 +3,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { AuthenticationService } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { OAuthTokenDto, OAuthService } from '@modules/oauth'; +import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { - ProvisioningService, ExternalSchoolDto, ExternalUserDto, OauthDataDto, + ProvisioningService, ProvisioningSystemDto, } from '@modules/provisioning'; import { ForbiddenException } from '@nestjs/common'; @@ -19,7 +19,7 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-prov import { legacySchoolDoFactory, setupEntities, - systemFactory, + systemEntityFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; @@ -339,7 +339,7 @@ describe(UserLoginMigrationUc.name, () => { describe('when external school and official school number is defined and school has to be migrated', () => { const setup = () => { - const sourceSystem: SystemEntity = systemFactory + const sourceSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); 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 f65d02c13a5..044d169864d 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -361,7 +361,6 @@ describe('UserService', () => { describe('when deleting by userId', () => { const setup = () => { const user1: User = userFactory.asStudent().buildWithId(); - userFactory.asStudent().buildWithId(); userRepo.findById.mockResolvedValue(user1); userRepo.deleteUser.mockResolvedValue(1); @@ -381,4 +380,33 @@ describe('UserService', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = () => { + const user: User = userFactory.asStudent().buildWithId(); + const parentEmail = ['test@test.eu']; + + userRepo.getParentEmailsFromUser.mockResolvedValue(parentEmail); + + return { + user, + parentEmail, + }; + }; + + it('should call userRepo.getParentEmailsFromUse', async () => { + const { user } = setup(); + + await service.getParentEmailsFromUser(user.id); + + expect(userRepo.getParentEmailsFromUser).toBeCalledWith(user.id); + }); + + it('should return array with parent emails', async () => { + const { user, parentEmail } = setup(); + + const result = await service.getParentEmailsFromUser(user.id); + expect(result).toEqual(parentEmail); + }); + }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 6ef014f8696..8f6feca4750 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -120,4 +120,10 @@ export class UserService { return deletedUserNumber; } + + async getParentEmailsFromUser(userId: EntityId): Promise { + const parentEmails = this.userRepo.getParentEmailsFromUser(userId); + + return parentEmails; + } } diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 62931e418dd..bba64a9dd9b 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -26,6 +27,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof DrawingElement || domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts index 89d1c297399..352bbaaa293 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts @@ -1,4 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { ContentElementFactory } from './content-element.factory'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -30,6 +31,14 @@ describe(ContentElementFactory.name, () => { expect(element).toBeInstanceOf(RichTextElement); }); + it('should return element of DRAWING', () => { + const { contentElementFactory } = setup(); + + const element = contentElementFactory.build(ContentElementType.DRAWING); + + expect(element).toBeInstanceOf(DrawingElement); + }); + it('should return element of SUBMISSION_CONTAINER', () => { const { contentElementFactory } = setup(); diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 8c34ca54b56..4f71b96bf8f 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -2,6 +2,7 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; +import { DrawingElement } from './drawing-element.do'; import { FileElement } from './file-element.do'; import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; @@ -23,6 +24,9 @@ export class ContentElementFactory { case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; + case ContentElementType.DRAWING: + element = this.buildDrawing(); + break; case ContentElementType.SUBMISSION_CONTAINER: element = this.buildSubmissionContainer(); break; @@ -78,6 +82,18 @@ export class ContentElementFactory { return element; } + private buildDrawing() { + const element = new DrawingElement({ + id: new ObjectId().toHexString(), + description: '', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildSubmissionContainer() { const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts new file mode 100644 index 00000000000..b8876c7c0b1 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(DrawingElement.name, () => { + describe('when trying to add a child to a drawing element', () => { + it('should throw an error ', () => { + const drawingElement = drawingElementFactory.build(); + const drawingElementChild = drawingElementFactory.build(); + + expect(() => drawingElement.addChild(drawingElementChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + drawingElement.accept(visitor); + + expect(visitor.visitDrawingElement).toHaveBeenCalledWith(drawingElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + await drawingElement.acceptAsync(visitor); + + expect(visitor.visitDrawingElementAsync).toHaveBeenCalledWith(drawingElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts new file mode 100644 index 00000000000..e4bf11936e8 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts @@ -0,0 +1,32 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class DrawingElement extends BoardComposite { + get description(): string { + return this.props.description; + } + + set description(value: string) { + this.props.description = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitDrawingElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitDrawingElementAsync(this); + } +} + +export interface DrawingElementProps extends BoardCompositeProps { + description: string; +} + +export function isDrawingElement(reference: unknown): reference is DrawingElement { + return reference instanceof DrawingElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 9701ba40099..bb82ee91e7c 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,6 +3,7 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './drawing-element.do'; export * from './external-tool-element.do'; export * from './file-element.do'; export * from './link-element.do'; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index 614071e658c..14239363aaf 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,4 +1,5 @@ import { ExternalToolElement } from '../external-tool-element.do'; +import { DrawingElement } from '../drawing-element.do'; import { FileElement } from '../file-element.do'; import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; @@ -6,6 +7,7 @@ import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; export type AnyContentElementDo = + | DrawingElement | ExternalToolElement | FileElement | LinkElement @@ -14,6 +16,7 @@ export type AnyContentElementDo = export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof DrawingElement || element instanceof ExternalToolElement || element instanceof FileElement || element instanceof LinkElement || diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 3fbd4abdd96..5e2547bbf6b 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '../drawing-element.do'; import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; @@ -15,6 +16,7 @@ export interface BoardCompositeVisitor { visitFileElement(fileElement: FileElement): void; visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; + visitDrawingElement(drawingElement: DrawingElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; visitExternalToolElement(externalToolElement: ExternalToolElement): void; @@ -27,6 +29,7 @@ export interface BoardCompositeVisitorAsync { visitFileElementAsync(fileElement: FileElement): Promise; visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; + visitDrawingElementAsync(drawingElement: DrawingElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise; diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index b8d4e166e25..151e7666c7f 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { FILE = 'file', + DRAWING = 'drawing', LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 9dc33c55b78..eb4b2478379 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -5,12 +5,14 @@ import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { Account } from './account.entity'; import { BoardNode, CardNode, ColumnBoardNode, ColumnNode, + DrawingElementNode, ExternalToolElementNodeEntity, FileElementNode, LinkElementNode, @@ -61,6 +63,7 @@ export const ALL_ENTITIES = [ FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, ExternalToolElementNodeEntity, @@ -100,4 +103,5 @@ export const ALL_ENTITIES = [ UserLoginMigrationEntity, VideoConference, GroupEntity, + RegistrationPinEntity, ]; diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts new file mode 100644 index 00000000000..ce868baff25 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts @@ -0,0 +1,59 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(DrawingElementNode.name, () => { + describe('when trying to create a drawing element', () => { + const setup = () => { + const elementProps = { description: '' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a DrawingElementNode', () => { + const { elementProps } = setup(); + + const element = new DrawingElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.DRAWING_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new DrawingElementNode({ description: '' }); + const builder: DeepMocked = createMock(); + const elementDo = drawingElementFactory.build(); + + builder.buildDrawingElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should return DrawingElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts new file mode 100644 index 00000000000..471e2290220 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts @@ -0,0 +1,25 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '@shared/domain/domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.DRAWING_ELEMENT }) +export class DrawingElementNode extends BoardNode { + @Property() + description: string; + + constructor(props: DrawingElementNodeProps) { + super(props); + this.type = BoardNodeType.DRAWING_ELEMENT; + this.description = props.description; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildDrawingElement(this); + return domainObject; + } +} + +export interface DrawingElementNodeProps extends BoardNodeProps { + description: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index a3a56e6dfe0..85b74b0adb9 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -5,6 +5,7 @@ export * from './column-node.entity'; export * from './external-tool-element-node.entity'; export * from './file-element-node.entity'; export * from './link-element-node.entity'; +export * from './drawing-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index 1b759a41180..1b61566d442 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -2,6 +2,7 @@ import type { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, LinkElement, @@ -12,6 +13,7 @@ import type { import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; +import type { DrawingElementNode } from '../drawing-element-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; import type { LinkElementNode } from '../link-element-node.entity'; @@ -23,6 +25,7 @@ export interface BoardDoBuilder { buildColumnBoard(boardNode: ColumnBoardNode): ColumnBoard; buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; + buildDrawingElement(boardNode: DrawingElementNode): DrawingElement; buildFileElement(boardNode: FileElementNode): FileElement; buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index 0b25a81b053..f76f5330d5e 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -5,6 +5,7 @@ export enum BoardNodeType { FILE_ELEMENT = 'file-element', LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', + DRAWING_ELEMENT = 'drawing-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', EXTERNAL_TOOL = 'external-tool', diff --git a/apps/server/src/shared/domain/entity/system.entity.spec.ts b/apps/server/src/shared/domain/entity/system.entity.spec.ts index 9b1c538673f..06c45b5c5e4 100644 --- a/apps/server/src/shared/domain/entity/system.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/system.entity.spec.ts @@ -1,6 +1,5 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { setupEntities } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { setupEntities, systemEntityFactory } from '@shared/testing'; import { SystemEntity } from './system.entity'; describe('System Entity', () => { @@ -16,13 +15,13 @@ describe('System Entity', () => { }); it('should create a system by passing required properties', () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); expect(system instanceof SystemEntity).toEqual(true); }); it('should create a system by passing required and optional properties', () => { - const system = systemFactory + const system = systemEntityFactory .withOauthConfig() .build({ url: 'SAMPLE_URL', alias: 'SAMPLE_ALIAS', displayName: 'SAMPLE_NAME' }); @@ -39,16 +38,16 @@ describe('System Entity', () => { clientId: '12345', clientSecret: 'mocksecret', idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', scope: 'openid uuid', responseType: 'code', - authEndpoint: 'http://mock.de/auth', + authEndpoint: 'https://mock.de/auth', provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', + logoutEndpoint: 'https://mock.de/logout', issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', + jwksEndpoint: 'https://mock.de/jwks', }, }) ); diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/shared/domain/entity/system.entity.ts index 07cfea5bb59..3f7f1c46c88 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/shared/domain/entity/system.entity.ts @@ -3,20 +3,20 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-prov import { EntityId } from '../types'; import { BaseEntityWithTimestamps } from './base.entity'; -export interface SystemProperties { +export interface SystemEntityProps { type: string; url?: string; alias?: string; displayName?: string; - oauthConfig?: OauthConfig; - oidcConfig?: OidcConfig; - ldapConfig?: LdapConfig; + oauthConfig?: OauthConfigEntity; + oidcConfig?: OidcConfigEntity; + ldapConfig?: LdapConfigEntity; provisioningStrategy?: SystemProvisioningStrategy; provisioningUrl?: string; } -export class OauthConfig { - constructor(oauthConfig: OauthConfig) { +export class OauthConfigEntity { + constructor(oauthConfig: OauthConfigEntity) { this.clientId = oauthConfig.clientId; this.clientSecret = oauthConfig.clientSecret; this.idpHint = oauthConfig.idpHint; @@ -73,8 +73,8 @@ export class OauthConfig { } @Embeddable() -export class LdapConfig { - constructor(ldapConfig: Readonly) { +export class LdapConfigEntity { + constructor(ldapConfig: Readonly) { this.active = ldapConfig.active; this.federalState = ldapConfig.federalState; this.lastSyncAttempt = ldapConfig.lastSyncAttempt; @@ -150,8 +150,8 @@ export class LdapConfig { }; }; } -export class OidcConfig { - constructor(oidcConfig: OidcConfig) { +export class OidcConfigEntity { + constructor(oidcConfig: OidcConfigEntity) { this.clientId = oidcConfig.clientId; this.clientSecret = oidcConfig.clientSecret; this.idpHint = oidcConfig.idpHint; @@ -189,7 +189,7 @@ export class OidcConfig { @Entity({ tableName: 'systems' }) export class SystemEntity extends BaseEntityWithTimestamps { - constructor(props: SystemProperties) { + constructor(props: SystemEntityProps) { super(); this.type = props.type; this.url = props.url; @@ -215,17 +215,17 @@ export class SystemEntity extends BaseEntityWithTimestamps { displayName?: string; @Property({ nullable: true }) - oauthConfig?: OauthConfig; + oauthConfig?: OauthConfigEntity; @Property({ nullable: true }) @Enum() provisioningStrategy?: SystemProvisioningStrategy; @Property({ nullable: true }) - oidcConfig?: OidcConfig; + oidcConfig?: OidcConfigEntity; - @Embedded({ entity: () => LdapConfig, object: true, nullable: true }) - ldapConfig?: LdapConfig; + @Embedded({ entity: () => LdapConfigEntity, object: true, nullable: true }) + ldapConfig?: LdapConfigEntity; @Property({ nullable: true }) provisioningUrl?: string; diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts new file mode 100644 index 00000000000..d5fe53251f9 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts @@ -0,0 +1,23 @@ +import { UserParentsEntity } from './user-parents.entity'; + +describe(UserParentsEntity.name, () => { + describe('constructor', () => { + describe('When a contructor is called', () => { + const setup = () => { + const entity = new UserParentsEntity({ firstName: 'firstName', lastName: 'lastName', email: 'test@test.eu' }); + + return { entity }; + }; + + it('should contain valid tspUid ', () => { + const { entity } = setup(); + + const userParentsEntity: UserParentsEntity = new UserParentsEntity(entity); + + expect(userParentsEntity.firstName).toEqual(entity.firstName); + expect(userParentsEntity.lastName).toEqual(entity.lastName); + expect(userParentsEntity.email).toEqual(entity.email); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.ts b/apps/server/src/shared/domain/entity/user-parents.entity.ts new file mode 100644 index 00000000000..a0709396880 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.ts @@ -0,0 +1,25 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +export interface UserParentsEntityProps { + firstName: string; + lastName: string; + email: string; +} + +@Embeddable() +export class UserParentsEntity { + @Property() + firstName: string; + + @Property() + lastName: string; + + @Property() + email: string; + + constructor(props: UserParentsEntityProps) { + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + } +} diff --git a/apps/server/src/shared/domain/entity/user.entity.ts b/apps/server/src/shared/domain/entity/user.entity.ts index c9a982c3854..dd5c0ec66b3 100644 --- a/apps/server/src/shared/domain/entity/user.entity.ts +++ b/apps/server/src/shared/domain/entity/user.entity.ts @@ -1,8 +1,9 @@ -import { Collection, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; +import { Collection, Embedded, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; import { EntityWithSchool } from '../interface'; import { BaseEntityWithTimestamps } from './base.entity'; import { Role } from './role.entity'; import { SchoolEntity } from './school.entity'; +import { UserParentsEntity } from './user-parents.entity'; export enum LanguageType { DE = 'de', @@ -27,6 +28,7 @@ export interface UserProperties { outdatedSince?: Date; previousExternalId?: string; birthday?: Date; + parents?: UserParentsEntity[]; } @Entity({ tableName: 'users' }) @@ -100,6 +102,9 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property({ nullable: true }) birthday?: Date; + @Embedded(() => UserParentsEntity, { array: true, nullable: true }) + parents?: UserParentsEntity[]; + constructor(props: UserProperties) { super(); this.firstName = props.firstName; @@ -117,6 +122,7 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { this.outdatedSince = props.outdatedSince; this.previousExternalId = props.previousExternalId; this.birthday = props.birthday; + this.parents = props.parents; } public resolvePermissions(): string[] { diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts index 097f5498685..090e9fc4fd9 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts @@ -18,7 +18,7 @@ import { legacySchoolDoFactory, schoolFactory, schoolYearFactory, - systemFactory, + systemEntityFactory, userLoginMigrationFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; @@ -114,7 +114,7 @@ describe('LegacySchoolRepo', () => { describe('findByExternalId', () => { it('should find school by external ID', async () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolEntity: SchoolEntity = schoolFactory.build({ externalId: 'externalId' }); schoolEntity.systems.add(system); @@ -182,7 +182,7 @@ describe('LegacySchoolRepo', () => { describe('mapEntityToDO is called', () => { it('should map school entity to school domain object', () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [system], features: [], schoolYear }); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.build({ school: schoolEntity }); @@ -219,8 +219,8 @@ describe('LegacySchoolRepo', () => { describe('mapDOToEntityProperties is called', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.buildWithId(); + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.buildWithId(); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId(); diff --git a/apps/server/src/shared/repo/system/index.ts b/apps/server/src/shared/repo/system/index.ts index cfc3117fc5f..2c071b949c9 100644 --- a/apps/server/src/shared/repo/system/index.ts +++ b/apps/server/src/shared/repo/system/index.ts @@ -1 +1 @@ -export * from './system.repo'; +export * from './legacy-system.repo'; diff --git a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts similarity index 82% rename from apps/server/src/shared/repo/system/system.repo.integration.spec.ts rename to apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts index d8a5c1d87e3..bf8d9ea18fa 100644 --- a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts @@ -1,22 +1,22 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { SystemRepo } from '@shared/repo'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; describe('system repo', () => { let module: TestingModule; - let repo: SystemRepo; + let repo: LegacySystemRepo; let em: EntityManager; beforeAll(async () => { module = await Test.createTestingModule({ imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [SystemRepo], + providers: [LegacySystemRepo], }).compile(); - repo = module.get(SystemRepo); + repo = module.get(LegacySystemRepo); em = module.get(EntityManager); }); @@ -39,7 +39,7 @@ describe('system repo', () => { }); it('should return right keys', async () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); await em.persistAndFlush([system]); const result = await repo.findById(system.id); expect(Object.keys(result).sort()).toEqual( @@ -61,7 +61,7 @@ describe('system repo', () => { }); it('should return a System that matched by id', async () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); await em.persistAndFlush([system]); const result = await repo.findById(system.id); expect(result).toEqual(system); @@ -80,7 +80,7 @@ describe('system repo', () => { }); it('should return all systems', async () => { - const systems = [systemFactory.build(), systemFactory.build({ oauthConfig: undefined })]; + const systems = [systemEntityFactory.build(), systemEntityFactory.build({ oauthConfig: undefined })]; await em.persistAndFlush(systems); const result = await repo.findAll(); @@ -91,9 +91,9 @@ describe('system repo', () => { }); describe('findByFilter', () => { - const ldapSystems = systemFactory.withLdapConfig().buildListWithId(2); - const oauthSystems = systemFactory.withOauthConfig().buildListWithId(2); - const oidcSystems = systemFactory.withOidcConfig().buildListWithId(2); + const ldapSystems = systemEntityFactory.withLdapConfig().buildListWithId(2); + const oauthSystems = systemEntityFactory.withOauthConfig().buildListWithId(2); + const oidcSystems = systemEntityFactory.withOidcConfig().buildListWithId(2); beforeAll(async () => { await em.persistAndFlush([...ldapSystems, ...oauthSystems, ...oidcSystems]); diff --git a/apps/server/src/shared/repo/system/system.repo.ts b/apps/server/src/shared/repo/system/legacy-system.repo.ts similarity index 81% rename from apps/server/src/shared/repo/system/system.repo.ts rename to apps/server/src/shared/repo/system/legacy-system.repo.ts index 65fd257cc24..85a8fdbdc84 100644 --- a/apps/server/src/shared/repo/system/system.repo.ts +++ b/apps/server/src/shared/repo/system/legacy-system.repo.ts @@ -3,8 +3,12 @@ import { SystemEntity, SystemTypeEnum } from '@shared/domain'; import { BaseRepo } from '@shared/repo/base.repo'; import { SystemScope } from '@shared/repo/system/system-scope'; +// TODO N21-1547: Fully replace this service with SystemService +/** + * @deprecated use the {@link SystemRepo} from the system module instead + */ @Injectable() -export class SystemRepo extends BaseRepo { +export class LegacySystemRepo extends BaseRepo { get entityName() { return SystemEntity; } 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 451285eebca..ef01a96503c 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 @@ -23,7 +23,7 @@ import { cleanupCollections, roleFactory, schoolFactory, - systemFactory, + systemEntityFactory, userDoFactory, userFactory, } from '@shared/testing'; @@ -146,7 +146,7 @@ describe('UserRepo', () => { let user: User; beforeEach(async () => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); @@ -190,7 +190,7 @@ describe('UserRepo', () => { let user: User; beforeEach(async () => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index 3d44d0edfb8..1ea116d995a 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -3,8 +3,15 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { MatchCreator, SortOrder, SystemEntity, User } from '@shared/domain'; -import { cleanupCollections, importUserFactory, roleFactory, schoolFactory, userFactory } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { + cleanupCollections, + importUserFactory, + roleFactory, + schoolFactory, + systemEntityFactory, + userFactory, +} from '@shared/testing'; +import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { UserRepo } from './user.repo'; describe('user repo', () => { @@ -64,6 +71,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -124,7 +132,7 @@ describe('user repo', () => { let userA: User; let userB: User; beforeEach(async () => { - sys = systemFactory.build(); + sys = systemEntityFactory.build(); await em.persistAndFlush([sys]); const school = schoolFactory.build({ systems: [sys] }); // const school = schoolFactory.withSystem().build(); @@ -154,6 +162,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -443,4 +452,36 @@ describe('user repo', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = async () => { + const parentOfUser: UserParentsEntityProps = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.eu', + }; + const user = userFactory.asStudent().buildWithId({ + parents: [parentOfUser], + }); + + const expectedParentEmail = [parentOfUser.email]; + + await em.persistAndFlush(user); + em.clear(); + + return { + user, + expectedParentEmail, + }; + }; + + describe('when searching user parent email', () => { + it('should return array witn parent email', async () => { + const { user, expectedParentEmail } = await setup(); + const result = await repo.getParentEmailsFromUser(user.id); + + expect(result).toEqual(expectedParentEmail); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 44acafe6a80..2f693ab7124 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -170,6 +170,13 @@ export class UserRepo extends BaseRepo { return deletedUserNumber; } + async getParentEmailsFromUser(userId: EntityId): Promise { + const user = await this._em.findOneOrFail(User, { id: userId }); + const parentsEmails = user.parents?.map((parent) => parent.email) ?? []; + + return parentsEmails; + } + private async populateRoles(roles: Role[]): Promise { for (let i = 0; i < roles.length; i += 1) { const role = roles[i]; diff --git a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts index 266cd0381c1..a78424f7525 100644 --- a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts @@ -1,10 +1,10 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { cleanupCollections, schoolFactory, systemFactory } from '@shared/testing'; +import { cleanupCollections, schoolFactory, systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { userLoginMigrationFactory } from '../../testing/factory/user-login-migration.factory'; import { UserLoginMigrationRepo } from './user-login-migration.repo'; @@ -42,8 +42,8 @@ describe('UserLoginMigrationRepo', () => { describe('when saving a UserLoginMigrationDO', () => { const setup = async () => { const school: SchoolEntity = schoolFactory.buildWithId(); - const sourceSystem: SystemEntity = systemFactory.buildWithId(); - const targetSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + const targetSystem: SystemEntity = systemEntityFactory.buildWithId(); const domainObject: UserLoginMigrationDO = new UserLoginMigrationDO({ schoolId: school.id, diff --git a/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts new file mode 100644 index 00000000000..0298bee52b9 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +import { DrawingElementNode, DrawingElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const drawingElementNodeFactory = BaseFactory.define( + DrawingElementNode, + ({ sequence }) => { + return { + description: `test-description-${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts new file mode 100644 index 00000000000..526dbfe2869 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts @@ -0,0 +1,18 @@ +/* istanbul ignore file */ +import { ObjectId } from 'bson'; +import { DrawingElement, DrawingElementProps } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BaseFactory } from '../../base.factory'; + +export const drawingElementFactory = BaseFactory.define( + DrawingElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + title: `element #${sequence}`, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + description: '', + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index 802dcf744f3..cff2ebf8833 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,6 +1,7 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './drawing-element.do.factory'; export * from './external-tool-element.do.factory'; export * from './file-element.do.factory'; export * from './link-element.do.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 8e02c37399c..9314c2a829e 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -7,3 +7,4 @@ export * from './domain-object.factory'; export * from './user-login-migration-do.factory'; export * from './lti-tool.factory'; export * from './pseudonym.factory'; +export { systemFactory } from './system/system.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts b/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts new file mode 100644 index 00000000000..35e1c3f438c --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts @@ -0,0 +1,10 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { System, SystemProps } from '@modules/system/domain'; +import { DomainObjectFactory } from '../domain-object.factory'; + +export const systemFactory = DomainObjectFactory.define(System, () => { + return { + id: new ObjectId().toHexString(), + type: 'oauth2', + }; +}); diff --git a/apps/server/src/shared/testing/factory/group-entity.factory.ts b/apps/server/src/shared/testing/factory/group-entity.factory.ts index 482aca971cf..1e65d13d808 100644 --- a/apps/server/src/shared/testing/factory/group-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/group-entity.factory.ts @@ -1,9 +1,9 @@ -import { ExternalSourceEntity, RoleName } from '@shared/domain'; import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupValidPeriodEntity } from '@modules/group/entity'; +import { ExternalSourceEntity, RoleName } from '@shared/domain'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; import { schoolFactory } from './school.factory'; -import { systemFactory } from './system.factory'; +import { systemEntityFactory } from './systemEntityFactory'; import { userFactory } from './user.factory'; export const groupEntityFactory = BaseFactory.define(GroupEntity, ({ sequence }) => { @@ -27,7 +27,7 @@ export const groupEntityFactory = BaseFactory.define { matched(matchedBy: MatchCreator, user: User): this { @@ -16,7 +15,7 @@ class ImportUserFactory extends BaseFactory { export const importUserFactory = ImportUserFactory.define(ImportUser, ({ sequence }) => { return { school: schoolFactory.build(), - system: systemFactory.build(), + system: systemEntityFactory.build(), ldapDn: `uid=john${sequence},cn=schueler,cn=users,ou=1,dc=training,dc=ucs`, // eslint-disable-next-line @typescript-eslint/no-unsafe-call externalId: uuidv4() as unknown as string, diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 24dac296de2..c177ae70942 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -29,7 +29,7 @@ export * from './schoolyear.factory'; export * from './share-token.do.factory'; export * from './storageprovider.factory'; export * from './submission.factory'; -export * from './system.factory'; +export * from './systemEntityFactory'; export * from './task.factory'; export * from './team.factory'; export * from './teamuser.factory'; diff --git a/apps/server/src/shared/testing/factory/system.factory.ts b/apps/server/src/shared/testing/factory/systemEntityFactory.ts similarity index 58% rename from apps/server/src/shared/testing/factory/system.factory.ts rename to apps/server/src/shared/testing/factory/systemEntityFactory.ts index f686c406851..567645210af 100644 --- a/apps/server/src/shared/testing/factory/system.factory.ts +++ b/apps/server/src/shared/testing/factory/systemEntityFactory.ts @@ -1,33 +1,33 @@ -import { LdapConfig, OauthConfig, OidcConfig, SystemEntity, SystemProperties } from '@shared/domain'; +import { LdapConfigEntity, OauthConfigEntity, OidcConfigEntity, SystemEntity, SystemEntityProps } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; -export class SystemFactory extends BaseFactory { +export class SystemEntityFactory extends BaseFactory { withOauthConfig(): this { - const params: DeepPartial = { - oauthConfig: new OauthConfig({ + const params: DeepPartial = { + oauthConfig: new OauthConfigEntity({ clientId: '12345', clientSecret: 'mocksecret', idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', scope: 'openid uuid', responseType: 'code', - authEndpoint: 'http://mock.de/auth', + authEndpoint: 'https://mock.de/auth', provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', + logoutEndpoint: 'https://mock.de/logout', issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', + jwksEndpoint: 'https://mock.de/jwks', }), }; return this.params(params); } - withLdapConfig(otherParams?: DeepPartial): this { - const params: DeepPartial = { - ldapConfig: new LdapConfig({ + withLdapConfig(otherParams?: DeepPartial): this { + const params: DeepPartial = { + ldapConfig: new LdapConfigEntity({ url: 'ldaps:mock.de:389', active: true, ...otherParams, @@ -39,7 +39,7 @@ export class SystemFactory extends BaseFactory { withOidcConfig(): this { const params = { - oidcConfig: new OidcConfig({ + oidcConfig: new OidcConfigEntity({ clientId: 'mock-client-id', clientSecret: 'mock-client-secret', idpHint: 'mock-oidc-idpHint', @@ -54,10 +54,10 @@ export class SystemFactory extends BaseFactory { } } -export const systemFactory = SystemFactory.define(SystemEntity, ({ sequence }) => { +export const systemEntityFactory = SystemEntityFactory.define(SystemEntity, ({ sequence }) => { return { type: 'oauth', - url: 'http://mock.de', + url: 'https://mock.de', alias: `system #${sequence}`, displayName: `system #${sequence}DisplayName`, provisioningStrategy: SystemProvisioningStrategy.OIDC, diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts new file mode 100644 index 00000000000..d5059777cef --- /dev/null +++ b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts @@ -0,0 +1,12 @@ +import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; +import WebSocket from 'ws'; + +export class TldrawWsFactory { + public static createWsSharedDocDo(): WsSharedDocDo { + return { conns: new Map(), destroy: () => {} } as WsSharedDocDo; + } + + public static createWebsocket(readyState: number): WebSocket { + return { readyState, close: () => {} } as WebSocket; + } +} diff --git a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts index a3759a853ca..a3feaedbd27 100644 --- a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts +++ b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts @@ -1,7 +1,7 @@ import { IUserLoginMigration, UserLoginMigrationEntity } from '../../domain/entity/user-login-migration.entity'; import { BaseFactory } from './base.factory'; import { schoolFactory } from './school.factory'; -import { systemFactory } from './system.factory'; +import { systemEntityFactory } from './systemEntityFactory'; export const userLoginMigrationFactory = BaseFactory.define( UserLoginMigrationEntity, @@ -9,7 +9,7 @@ export const userLoginMigrationFactory = BaseFactory.define=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/compat-data": { "version": "7.17.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz", @@ -1902,28 +1961,20 @@ } }, "node_modules/@babel/generator": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.0.tgz", - "integrity": "sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", + "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, "dependencies": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" + "@babel/types": "^7.23.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz", @@ -1952,50 +2003,34 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, - "dependencies": { - "@babel/types": "^7.16.7" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", - "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/helper-get-function-arity": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/types": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-get-function-arity": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", - "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.16.7" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -2054,21 +2089,30 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" @@ -2098,13 +2142,13 @@ } }, "node_modules/@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -2168,9 +2212,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -2381,33 +2425,33 @@ } }, "node_modules/@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.0.tgz", - "integrity": "sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.0", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.0", - "@babel/types": "^7.17.0", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", + "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.4", + "@babel/generator": "^7.23.4", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.4", + "@babel/types": "^7.23.4", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2416,12 +2460,13 @@ } }, "node_modules/@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -4462,6 +4507,14 @@ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -4941,6 +4994,44 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "dependencies": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -5101,6 +5192,28 @@ } } }, + "node_modules/@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13634,6 +13747,15 @@ "ws": "*" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -16159,6 +16281,25 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/libphonenumber-js": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -16704,8 +16845,7 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "node_modules/memory-stream": { "version": "0.0.3", @@ -18789,6 +18929,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -22338,7 +22486,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -25008,6 +25155,90 @@ "node": ">=0.4" } }, + "node_modules/y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "dependencies": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "node_modules/y-mongodb-provider/node_modules/bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/y-mongodb-provider/node_modules/mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -25196,6 +25427,22 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -25543,7 +25790,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "dev": true, "optional": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", @@ -25745,7 +25991,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -25901,7 +26146,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -26575,12 +26819,56 @@ } }, "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "dev": true, "requires": { - "@babel/highlight": "^7.16.7" + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } } }, "@babel/compat-data": { @@ -26621,22 +26909,15 @@ } }, "@babel/generator": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.0.tgz", - "integrity": "sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", + "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, "requires": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - } + "@babel/types": "^7.23.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" } }, "@babel/helper-compilation-targets": { @@ -26660,41 +26941,28 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", - "dev": true, - "requires": { - "@babel/types": "^7.16.7" - } + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true }, "@babel/helper-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", - "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-get-function-arity": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", - "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", - "dev": true, - "requires": { - "@babel/types": "^7.16.7" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "requires": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" } }, "@babel/helper-module-imports": { @@ -26738,18 +27006,24 @@ } }, "@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "requires": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" } }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true }, "@babel/helper-validator-option": { @@ -26770,13 +27044,13 @@ } }, "@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "dependencies": { @@ -26824,9 +27098,9 @@ } }, "@babel/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -26974,41 +27248,42 @@ } }, "@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.0.tgz", - "integrity": "sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.0", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.0", - "@babel/types": "^7.17.0", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", + "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.4", + "@babel/generator": "^7.23.4", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.4", + "@babel/types": "^7.23.4", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.16.7", + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, @@ -28429,6 +28704,14 @@ "integrity": "sha512-TrCdPsM7DApxrK3avBbijT6/6Er4TZhtiQ+qlMqtqva13vMCG4HiF2vIWGrKJbFukkLRuhOfZlES+KZ9Y1Lx2A==", "requires": {} }, + "@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, "@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -28695,6 +28978,23 @@ "tslib": "2.6.2" } }, + "@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "requires": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "dependencies": { + "ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "requires": {} + } + } + }, "@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -28792,6 +29092,16 @@ "tslib": "2.6.2" } }, + "@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "requires": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -35293,6 +35603,11 @@ "peer": true, "requires": {} }, + "isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -37247,6 +37562,14 @@ "type-check": "~0.4.0" } }, + "lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "requires": { + "isomorphic.js": "^0.2.4" + } + }, "libphonenumber-js": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -37698,8 +38021,7 @@ "memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "memory-stream": { "version": "0.0.3", @@ -39345,6 +39667,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, "object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -42007,7 +42334,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "requires": { "memory-pager": "^1.0.2" } @@ -44048,6 +44374,40 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "requires": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "dependencies": { + "bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==" + }, + "mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "requires": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + } + } + } + }, + "y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "requires": { + "lib0": "^0.2.85" + } + }, "y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -44198,6 +44558,14 @@ "buffer-crc32": "~0.2.3" } }, + "yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "requires": { + "lib0": "^0.2.74" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 6e179f67264..854cb849b0e 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nest:start:h5p:library-management": "nest start h5p-library-management", "nest:start:h5p:library-management:dev": "nest start h5p-library-management --debug --watch", "nest:start:h5p:library-management:prod": "node dist/apps/server/apps/h5p-library-management.app", + "nest:start:tldraw": "nest start tldraw", + "nest:start:tldraw:dev": "nest start tldraw --debug --watch", + "nest:start:tldraw:prod": "node dist/apps/server/apps/tldraw.app", "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", @@ -121,7 +124,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", + "@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", @@ -221,7 +226,10 @@ "universal-analytics": "^0.5.1", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", - "winston": "^3.8.2" + "winston": "^3.8.2", + "y-mongodb-provider": "^0.1.7", + "y-protocols": "^1.0.5", + "yjs": "^13.6.7" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 62615f0efb1..018722039b5 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -63,6 +63,7 @@ const exposedVars = [ 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_TLDRAW_ENABLED', ]; /** diff --git a/src/services/system/hooks/verifyPayload.js b/src/services/system/hooks/verifyPayload.js index d1a40e36e7d..8933aecbf0f 100644 --- a/src/services/system/hooks/verifyPayload.js +++ b/src/services/system/hooks/verifyPayload.js @@ -2,7 +2,8 @@ const { Forbidden } = require('../../../errors'); module.exports = async (context) => { const currentSystem = await context.app.service('systems').get(context.id); - if (currentSystem.type === 'ldap' && currentSystem.ldapConfig && currentSystem.ldapConfig.provider === 'iserv-idm') { + + if (!context.app.service('nest-system-rule').canEdit(currentSystem)) { throw new Forbidden('Not allowed to change this system'); } diff --git a/src/services/system/model.js b/src/services/system/model.js index 9491b3cdbe2..1a4650348a4 100644 --- a/src/services/system/model.js +++ b/src/services/system/model.js @@ -18,6 +18,7 @@ const types = [ 'iserv', // SSO providers 'ldap', // general and provider-specific LDAP 'oidc', + 'oauth', tspBaseType, tspSchoolType, // Thüringer Schul-Portal ]; diff --git a/test/services/system/index.test.js b/test/services/system/index.test.js index 95e84bf15c3..985e5886851 100644 --- a/test/services/system/index.test.js +++ b/test/services/system/index.test.js @@ -132,7 +132,7 @@ describe('systemId service', () => { it('CREATE fails without the right permissions', async () => { const usersSchool = await testObjects.createTestSchool(); - const data = { type: 'ldap' }; + const data = { type: 'ldap', ldapConfig: { provider: 'general' } }; const user = await testObjects.createTestUser({ roles: ['student'], schoolId: [usersSchool._id] }); const params = await testObjects.generateRequestParamsFromUser(user); @@ -154,6 +154,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -167,7 +168,7 @@ describe('systemId service', () => { it('CREATE system is added to the users school after its creation', async () => { const usersSchool = await testObjects.createTestSchool(); - const data = { type: 'ldap' }; + const data = { type: 'ldap', ldapConfig: { provider: 'general' } }; const user = await testObjects.createTestUser({ roles: ['administrator'], schoolId: [usersSchool._id] }); const params = await testObjects.generateRequestParamsFromUser(user); @@ -186,6 +187,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -195,6 +197,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -215,6 +218,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -225,6 +229,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -245,6 +250,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -254,6 +260,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -263,7 +270,7 @@ describe('systemId service', () => { const result = await app.service('systems').update(usersSystem._id, data, params); expect(result.ldapConfig.searchUserPassword).to.be.undefined; }); - it('UPDATE iServ configuration should not be editable', async () => { + it('UPDATE global configuration should not be editable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -302,6 +309,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -328,6 +336,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -354,6 +363,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -369,7 +379,7 @@ describe('systemId service', () => { expect(result.ldapConfig.searchUserPassword).to.be.undefined; }); - it('PATCH iServ configuration should not be editable', async () => { + it('PATCH global configuration should not be editable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -403,6 +413,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -425,6 +436,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -447,6 +459,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -461,6 +474,9 @@ describe('systemId service', () => { it('REMOVE system is removed from the school after its removal', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -477,6 +493,9 @@ describe('systemId service', () => { it('REMOVE should remove ldapschoolidentifier from school if ldap system is removed', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id], @@ -496,6 +515,9 @@ describe('systemId service', () => { it('REMOVE should remove ldapLastSync from school if ldap system is removed', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id], @@ -512,7 +534,7 @@ describe('systemId service', () => { expect(usersSchoolUpdated.ldapLastSync).to.be.undefined; }); - it('REMOVE iServ configuration should not be removable', async () => { + it('REMOVE global configuration should not be removable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -533,5 +555,24 @@ describe('systemId service', () => { expect(err.message).to.equal('Not allowed to change this system'); } }); + + it('REMOVE non-ldap configuration should not be removable', async () => { + const usersSystem = await testObjects.createTestSystem({ + type: 'oauth', + }); + const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); + + const user = await testObjects.createTestUser({ roles: ['administrator'], schoolId: [usersSchool._id] }); + const params = await testObjects.generateRequestParamsFromUser(user); + + try { + await app.service('systems').remove(usersSystem._id, params); + throw new Error('should have failed'); + } catch (err) { + expect(err.message).to.not.equal('should have failed'); + expect(err.code).to.equal(403); + expect(err.message).to.equal('Not allowed to change this system'); + } + }); }); }); diff --git a/test/utils/setup.nest.services.js b/test/utils/setup.nest.services.js index 4de3bce181f..5c56311df27 100644 --- a/test/utils/setup.nest.services.js +++ b/test/utils/setup.nest.services.js @@ -15,6 +15,8 @@ const { DB_PASSWORD, DB_URL, DB_USERNAME } = require('../../dist/apps/server/con const { ALL_ENTITIES } = require('../../dist/apps/server/shared/domain/entity/all-entities'); const { TeamService } = require('../../dist/apps/server/modules/teams/service/team.service'); const { TeamsApiModule } = require('../../dist/apps/server/modules/teams/teams-api.module'); +const { AuthorizationModule } = require('../../dist/apps/server/modules/authorization'); +const { SystemRule } = require('../../dist/apps/server/modules/authorization'); const setupNestServices = async (app) => { const module = await Test.createTestingModule({ @@ -31,6 +33,7 @@ const setupNestServices = async (app) => { ConfigModule.forRoot({ ignoreEnvFile: true, ignoreEnvVars: true, isGlobal: true }), AccountApiModule, TeamsApiModule, + AuthorizationModule, ], }).compile(); const nestApp = await module.createNestApplication().init(); @@ -39,11 +42,13 @@ const setupNestServices = async (app) => { const accountService = nestApp.get(AccountService); const accountValidationService = nestApp.get(AccountValidationService); const teamService = nestApp.get(TeamService); + const systemRule = nestApp.get(SystemRule); app.services['nest-account-uc'] = accountUc; app.services['nest-account-service'] = accountService; app.services['nest-account-validation-service'] = accountValidationService; app.services['nest-team-service'] = teamService; + app.services['nest-system-rule'] = systemRule; app.services['nest-orm'] = orm; return { nestApp, orm, accountUc, accountService };