diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 525dd04c94f..6ca383f87d0 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -1,6 +1,6 @@ name: Reminder to update seed data after migration # If this workflow fails, it is a hint that you forgot to update the seed data after a migration. -# It is only a hint, because it only checks if you updated the migration collection in the seed data. +# It is only a hint, because it only checks if you updated the migration collection in the seed data. # It is not a check that you updated the whole seed data correctly. # See the documentation for advice: https://documentation.dbildungscloud.dev/docs/schulcloud-server/Migrations#committing-a-migration @@ -11,7 +11,7 @@ on: branches: [ main ] env: - MONGODB_VERSION: 5.0 + MONGODB_VERSION: 6.0 NODE_VERSION: '18' jobs: migration: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d759947fea..170ef80cd1d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ permissions: contents: read env: - MONGODB_VERSION: 5.0 + MONGODB_VERSION: 6.0 NODE_VERSION: '18' jobs: feathers_tests_cov: diff --git a/.gitignore b/.gitignore index 130655cf16d..a045507c02b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ build /coverage /.nyc_output /.idea/ +/apps/server/src/modules/board/loadtest/**/*.html +/apps/server/src/modules/board/loadtest/artilleryreport.json diff --git a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 index a3290e08f3e..e369044ea85 100644 --- a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 +++ b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 @@ -30,6 +30,11 @@ spec: git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true volumes: - name: libraries-list configMap: diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 06982d218fe..dcc0a88cd79 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -113,12 +113,6 @@ namespace: "{{ NAMESPACE }}" template: api-files-deployment.yml.j2 - - name: FileStorageDeployment - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: api-files-deployment.yml.j2 - - name: File Storage Ingress kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 index ce777261dd0..8b06a2a96ca 100644 --- a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 @@ -14,3 +14,10 @@ data: CALENDAR_URI: "{{ CALENDAR_URI }}" ROCKET_CHAT_URI: "{{ ROCKET_CHAT_URI }}" ETHERPAD__PAD_URI: "https://{{ DOMAIN }}/etherpad/p" + FEATURE_IDENTITY_MANAGEMENT_ENABLED: "{{ FEATURE_IDENTITY_MANAGEMENT_ENABLED }}" + FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: "{{ FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED }}" + FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: "{{ FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED }}" + IDENTITY_MANAGEMENT__INTERNAL_URI: "{{ IDENTITY_MANAGEMENT__INTERNAL_URI }}" + IDENTITY_MANAGEMENT__EXTERNAL_URI: "{{ IDENTITY_MANAGEMENT__EXTERNAL_URI }}" + IDENTITY_MANAGEMENT__TENANT: "{{ IDENTITY_MANAGEMENT__TENANT }}" + IDENTITY_MANAGEMENT__CLIENTID: "{{ IDENTITY_MANAGEMENT__CLIENTID }}" diff --git a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 index 84d6f11e4fb..95443ef537b 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 @@ -20,6 +20,11 @@ spec: spec: template: spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true containers: - name: delete-s3-files-cronjob image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} 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 index 7f350b86c97..a8c02d02769 100644 --- 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 @@ -29,6 +29,11 @@ spec: spec: template: spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true containers: - name: data-deletion-trigger-cronjob image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} diff --git a/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 b/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 index f9b76dc34a7..42edd22f4a0 100644 --- a/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 @@ -11,6 +11,11 @@ spec: labels: app: api-migration spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true containers: - name: api-migration-job image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 index 80b8e5e5e41..3f702e42e72 100644 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 @@ -20,6 +20,11 @@ spec: spec: template: spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true containers: - name: tldraw-delete-files-cronjob image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index bb30700349e..ca00617cc7f 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -202,6 +202,21 @@ data: # the additional namespace intended for use for the testing (and development) purposes if one want # to test anything that includes signing in with the IServ on nbc instance, but don't want to use # the default dev nbc instance as it would require merging the code to the main branch first. + # Removed oauth config + # "oauthConfig": { + # "clientId": "'$ISERV_OAUTH_CLIENT_ID'", + # "clientSecret": "'$ISERV_OAUTH_CLIENT_SECRET'", + # "tokenEndpoint": "'$ISERV_URL'/iserv/auth/public/token", + # "grantType": "authorization_code", + # "scope": "openid uuid", + # "responseType": "code", + # "redirectUri": "https://'$NS'.nbc.dbildungscloud.dev/api/v3/sso/oauth", + # "authEndpoint": "'$ISERV_URL'/iserv/auth/auth", + # "provider": "iserv", + # "logoutEndpoint": "'$ISERV_URL'/iserv/auth/logout", + # "jwksEndpoint": "'$ISERV_URL'/iserv/public/jwk", + # "issuer": "'$ISERV_URL'" + # } if [ "$SC_THEME" = "n21" ] && [[ "$NS" =~ ^(main|iserv-test)$ ]]; then ISERV_SYSTEM_ID=0000d186816abba584714c92 @@ -235,20 +250,6 @@ data: }, "type": "ldap", "provisioningStrategy": "iserv", - "oauthConfig": { - "clientId": "'$ISERV_OAUTH_CLIENT_ID'", - "clientSecret": "'$ISERV_OAUTH_CLIENT_SECRET'", - "tokenEndpoint": "'$ISERV_URL'/iserv/auth/public/token", - "grantType": "authorization_code", - "scope": "openid uuid", - "responseType": "code", - "redirectUri": "https://'$NS'.nbc.dbildungscloud.dev/api/v3/sso/oauth", - "authEndpoint": "'$ISERV_URL'/iserv/auth/auth", - "provider": "iserv", - "logoutEndpoint": "'$ISERV_URL'/iserv/auth/logout", - "jwksEndpoint": "'$ISERV_URL'/iserv/public/jwk", - "issuer": "'$ISERV_URL'" - } }, { "upsert": true diff --git a/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 index b6c777a1ef2..ffd8bc98ae5 100644 --- a/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 @@ -27,7 +27,7 @@ spec: mountPath: /update.sh subPath: update.sh command: ['/bin/sh','-c'] - args: ['cp /update.sh /update.run.sh && chmod +x /update.run.sh &&./update.run.sh'] + args: ['cp /update.sh /update.run.sh && chmod +x /update.run.sh && ./update.run.sh'] resources: limits: cpu: "3000m" diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 94af5defccc..0718e15a6e4 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -4,7 +4,6 @@ import { Mail, MailService } from '@infra/mail'; /* eslint-disable no-console */ import { MikroORM } from '@mikro-orm/core'; import { AccountService } from '@modules/account'; -import { AccountValidationService } from '@src/modules/account/domain/services/account.validation.service'; import { AccountUc } from '@src/modules/account/api/account.uc'; import { SystemRule } from '@modules/authorization/domain/rules'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; @@ -83,8 +82,6 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-account-service'] = nestApp.get(AccountService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - feathersExpress.services['nest-account-validation-service'] = nestApp.get(AccountValidationService); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-account-uc'] = nestApp.get(AccountUc); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-collaborative-storage-uc'] = nestApp.get(CollaborativeStorageUc); diff --git a/apps/server/src/core/logger/types/logging.types.ts b/apps/server/src/core/logger/types/logging.types.ts index 5271ba85338..3bd1839fd8b 100644 --- a/apps/server/src/core/logger/types/logging.types.ts +++ b/apps/server/src/core/logger/types/logging.types.ts @@ -1,3 +1,6 @@ +/** + * Information inside this file should be placed in shared, type are copied to it. + */ export type LogMessage = { message: string; data?: LogMessageData; @@ -7,7 +10,7 @@ export type ErrorLogMessage = { error?: Error; type: string; // TODO: use enum stack?: string; - data?: { [key: string]: string | number | boolean | undefined }; + data?: LogMessageDataObject; }; export type ValidationErrorLogMessage = { diff --git a/apps/server/src/infra/authorization-client/authorization-client.adapter.ts b/apps/server/src/infra/authorization-client/authorization-client.adapter.ts index daf155fdf14..711aec8717f 100644 --- a/apps/server/src/infra/authorization-client/authorization-client.adapter.ts +++ b/apps/server/src/infra/authorization-client/authorization-client.adapter.ts @@ -1,9 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { RawAxiosRequestConfig } from 'axios'; -import cookie from 'cookie'; import { Request } from 'express'; -import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt'; +import { extractJwtFromHeader } from '@shared/common'; import { AuthorizationApi, AuthorizationBodyParams } from './authorization-api-client'; import { AuthorizationErrorLoggableException, AuthorizationForbiddenLoggableException } from './error'; @@ -19,9 +18,9 @@ export class AuthorizationClientAdapter { } public async hasPermissionsByReference(params: AuthorizationBodyParams): Promise { - const options = this.createOptionParams(params); - try { + const options = this.createOptionParams(); + const response = await this.authorizationApi.authorizationReferenceControllerAuthorizeByReference( params, options @@ -34,34 +33,20 @@ export class AuthorizationClientAdapter { } } - private createOptionParams(params: AuthorizationBodyParams): RawAxiosRequestConfig { - const jwt = this.getJWT(params); + private createOptionParams(): RawAxiosRequestConfig { + const jwt = this.getJwt(); const options: RawAxiosRequestConfig = { headers: { authorization: `Bearer ${jwt}` } }; return options; } - private getJWT(params: AuthorizationBodyParams): string { - const getJWT = ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), this.fromCookie('jwt')]); - const jwt = getJWT(this.request) || this.request.headers.authorization; + private getJwt(): string { + const jwt = extractJwtFromHeader(this.request) || this.request.headers.authorization; if (!jwt) { - const error = new Error('Authentication is required.'); - throw new AuthorizationErrorLoggableException(error, params); + throw new Error('Authentication is required.'); } return jwt; } - - private fromCookie(name: string): JwtFromRequestFunction { - return (request: Request) => { - let token: string | null = null; - const cookies = cookie.parse(request.headers.cookie || ''); - if (cookies && cookies[name]) { - token = cookies[name]; - } - - return token; - }; - } } diff --git a/apps/server/src/infra/identity-management/identity-management-oauth.service.ts b/apps/server/src/infra/identity-management/identity-management-oauth.service.ts index 2a486e87c32..d971a0dddd9 100644 --- a/apps/server/src/infra/identity-management/identity-management-oauth.service.ts +++ b/apps/server/src/infra/identity-management/identity-management-oauth.service.ts @@ -1,4 +1,4 @@ -import { OauthConfigDto } from '@modules/system/service/dto'; +import type { OauthConfig } from '@modules/system'; export abstract class IdentityManagementOauthService { /** @@ -6,7 +6,7 @@ export abstract class IdentityManagementOauthService { * @returns the oauth config of the IDM. * @throws an error if the IDM oauth config is not available. */ - abstract getOauthConfig(): Promise; + abstract getOauthConfig(): Promise; /** * Checks if the IDM oauth config is available. diff --git a/apps/server/src/infra/identity-management/identity-management.module.ts b/apps/server/src/infra/identity-management/identity-management.module.ts index 0a98f025427..9188e08bbe6 100644 --- a/apps/server/src/infra/identity-management/identity-management.module.ts +++ b/apps/server/src/infra/identity-management/identity-management.module.ts @@ -1,5 +1,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { EncryptionModule } from '../encryption'; import { IdentityManagementOauthService } from './identity-management-oauth.service'; import { IdentityManagementService } from './identity-management.service'; @@ -9,7 +10,7 @@ import { KeycloakIdentityManagementOauthService } from './keycloak/service/keycl import { KeycloakIdentityManagementService } from './keycloak/service/keycloak-identity-management.service'; @Module({ - imports: [KeycloakModule, KeycloakAdministrationModule, HttpModule, EncryptionModule], + imports: [KeycloakModule, KeycloakAdministrationModule, HttpModule, EncryptionModule, LoggerModule], providers: [ { provide: IdentityManagementService, useClass: KeycloakIdentityManagementService }, { provide: IdentityManagementOauthService, useClass: KeycloakIdentityManagementOauthService }, diff --git a/apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts b/apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts index 6c0b3205669..d2ab4be195a 100644 --- a/apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts +++ b/apps/server/src/infra/identity-management/keycloak-administration/interface/keycloak-settings.interface.ts @@ -1,7 +1,8 @@ export const KeycloakSettings = Symbol('KeycloakSettings'); export interface IKeycloakSettings { - baseUrl: string; + internalBaseUrl: string; + externalBaseUrl: string; realmName: string; clientId: string; credentials: { diff --git a/apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts b/apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts index 0565ed0b2be..9bba3fa3cbe 100644 --- a/apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts +++ b/apps/server/src/infra/identity-management/keycloak-administration/keycloak-config.ts @@ -4,7 +4,8 @@ import { IKeycloakSettings } from './interface/keycloak-settings.interface'; export default class KeycloakAdministration { static keycloakSettings = (Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean) ? ({ - baseUrl: Configuration.get('IDENTITY_MANAGEMENT__URI') as string, + internalBaseUrl: Configuration.get('IDENTITY_MANAGEMENT__INTERNAL_URI') as string, + externalBaseUrl: Configuration.get('IDENTITY_MANAGEMENT__EXTERNAL_URI') as string, realmName: Configuration.get('IDENTITY_MANAGEMENT__TENANT') as string, clientId: Configuration.get('IDENTITY_MANAGEMENT__CLIENTID') as string, credentials: { diff --git a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts index 77e372bdc45..af3f51f2960 100644 --- a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.spec.ts @@ -15,7 +15,8 @@ describe('KeycloakAdministrationService', () => { const getSettings = (): IKeycloakSettings => { return { - baseUrl: 'http://localhost:8080', + internalBaseUrl: 'http://localhost:8080', + externalBaseUrl: 'http://localhost:8080', realmName: 'master', clientId: 'client', credentials: { @@ -110,7 +111,7 @@ describe('KeycloakAdministrationService', () => { describe('getWellKnownUrl', () => { it('should return the well known URL', () => { const wellKnownUrl = service.getWellKnownUrl(); - expect(wellKnownUrl).toContain(settings.baseUrl); + expect(wellKnownUrl).toContain(settings.internalBaseUrl); expect(wellKnownUrl).toContain(settings.realmName); }); }); diff --git a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts index a6e1669f869..07cf1b84d7a 100644 --- a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts +++ b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts @@ -13,7 +13,7 @@ export class KeycloakAdministrationService { @Inject(KeycloakSettings) private readonly kcSettings: IKeycloakSettings ) { this.kcAdminClient.setConfig({ - baseUrl: kcSettings.baseUrl, + baseUrl: kcSettings.internalBaseUrl, realmName: kcSettings.realmName, }); } @@ -33,7 +33,7 @@ export class KeycloakAdministrationService { } public getWellKnownUrl(): string { - return `${this.kcSettings.baseUrl}/realms/${this.kcSettings.realmName}/.well-known/openid-configuration`; + return `${this.kcSettings.externalBaseUrl}/realms/${this.kcSettings.realmName}/.well-known/openid-configuration`; } public getAdminUser(): string { diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts index 94ef9c042a4..cbf443d4d0a 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.spec.ts @@ -1,9 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; +import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import IdentityProviderRepresentation from '@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { OidcConfig } from '@modules/system/domain'; import { Test, TestingModule } from '@nestjs/testing'; -import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; -import { OidcConfigDto } from '@modules/system/service'; import { OidcIdentityProviderMapper } from './identity-provider.mapper'; describe('OidcIdentityProviderMapper', () => { @@ -32,8 +31,7 @@ describe('OidcIdentityProviderMapper', () => { describe('mapToKeycloakIdentityProvider', () => { const brokerFlowAlias = 'flow'; - const internalRepresentation: OidcConfigDto = { - parentSystemId: new ObjectId(0).toString(), + const internalRepresentation: OidcConfig = { clientId: 'clientId', clientSecret: 'clientSecret', idpHint: 'alias', diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts index a7f9e360074..75bd4ae3794 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/mapper/identity-provider.mapper.ts @@ -1,12 +1,12 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import IdentityProviderRepresentation from '@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation'; -import { OidcConfigDto } from '@modules/system/service'; +import type { OidcConfig } from '@modules/system'; import { Inject } from '@nestjs/common'; export class OidcIdentityProviderMapper { constructor(@Inject(DefaultEncryptionService) private readonly defaultEncryptionService: EncryptionService) {} - public mapToKeycloakIdentityProvider(oidcConfig: OidcConfigDto, flowAlias: string): IdentityProviderRepresentation { + public mapToKeycloakIdentityProvider(oidcConfig: OidcConfig, flowAlias: string): IdentityProviderRepresentation { return { providerId: 'oidc', alias: oidcConfig.idpHint, 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 bae96a2d119..01d7f066ca3 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,12 +1,10 @@ -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 { EntityManager } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySystemRepo } from '@shared/repo'; import { systemEntityFactory } from '@shared/testing/factory'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; @@ -17,14 +15,13 @@ import { KeycloakConfigurationService } from './keycloak-configuration.service'; describe('KeycloakConfigurationService Integration', () => { let module: TestingModule; let keycloak: KeycloakAdminClient; - let systemRepo: LegacySystemRepo; + let em: EntityManager; 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 = systemEntityFactory.withOidcConfig().buildList(1); beforeAll(async () => { @@ -38,15 +35,14 @@ describe('KeycloakConfigurationService Integration', () => { validationOptions: { infer: true }, }), ], - providers: [LegacySystemRepo], }).compile(); - systemRepo = module.get(LegacySystemRepo); + em = module.get(EntityManager); keycloakAdministrationService = module.get(KeycloakAdministrationService); keycloakConfigurationService = module.get(KeycloakConfigurationService); isKeycloakAvailable = await keycloakAdministrationService.testKcConnection(); if (isKeycloakAvailable) { keycloak = await keycloakAdministrationService.callKcAdminClient(); - await systemRepo.save(systems); + await em.persistAndFlush(systems); } }); @@ -137,7 +133,6 @@ describe('KeycloakConfigurationService Integration', () => { 'should sync identity providers to keycloak', async () => { if (!isKeycloakAvailable) return; - systemServiceMock.findByType.mockResolvedValueOnce(systems); await keycloakConfigurationService.configureBrokerFlows(); await keycloakConfigurationService.configureIdentityProviders(); 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 11bf59c9d8c..1e7f10c87e5 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 @@ -7,15 +7,12 @@ import { AuthenticationManagement } from '@keycloak/keycloak-admin-client/lib/re import { Clients } from '@keycloak/keycloak-admin-client/lib/resources/clients'; import { IdentityProviders } from '@keycloak/keycloak-admin-client/lib/resources/identityProviders'; import { Realms } from '@keycloak/keycloak-admin-client/lib/resources/realms'; -import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; -import { SystemOidcService } from '@modules/system/service/system-oidc.service'; +import { SystemService } from '@modules/system'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { SystemEntity } from '@shared/domain/entity'; -import { SystemTypeEnum } from '@shared/domain/types'; -import { systemEntityFactory } from '@shared/testing'; +import { systemFactory } from '@shared/testing'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { v1 } from 'uuid'; @@ -32,7 +29,7 @@ describe('KeycloakConfigurationService Unit', () => { let client: DeepMocked; let service: KeycloakConfigurationService; let configService: DeepMocked; - let systemOidcService: DeepMocked; + let systemService: DeepMocked; let httpServiceMock: DeepMocked; let settings: IKeycloakSettings; @@ -53,7 +50,8 @@ describe('KeycloakConfigurationService Unit', () => { const getSettings = (): IKeycloakSettings => { return { - baseUrl: 'http://localhost:8080', + internalBaseUrl: 'http://localhost:8080', + externalBaseUrl: 'http://localhost:8080', realmName: 'master', clientId: 'dBildungscloud', credentials: { @@ -65,14 +63,11 @@ describe('KeycloakConfigurationService Unit', () => { }; }; - const systems: SystemEntity[] = systemEntityFactory - .withOidcConfig() - .buildListWithId(1, { type: SystemTypeEnum.OIDC }); - const oidcSystems = SystemOidcMapper.mapFromEntitiesToDtos(systems); + const systems = systemFactory.withOidcConfig().buildList(1); const idps: IdentityProviderRepresentation[] = [ { providerId: 'oidc', - alias: oidcSystems[0].idpHint, + alias: systems[0].oidcConfig?.idpHint, enabled: true, config: { clientId: 'clientId', @@ -115,8 +110,8 @@ describe('KeycloakConfigurationService Unit', () => { }), }, { - provide: SystemOidcService, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: KeycloakSettings, @@ -142,7 +137,7 @@ describe('KeycloakConfigurationService Unit', () => { service = module.get(KeycloakConfigurationService); configService = module.get(ConfigService); settings = module.get(KeycloakSettings); - systemOidcService = module.get(SystemOidcService); + systemService = module.get(SystemService); httpServiceMock = module.get(HttpService); configService.get.mockImplementation((key: string) => `${key}-value`); @@ -153,7 +148,7 @@ describe('KeycloakConfigurationService Unit', () => { }); beforeEach(() => { - systemOidcService.findAll.mockResolvedValue(oidcSystems); + systemService.find.mockResolvedValue(systems); kcApiClientIdentityProvidersMock.find.mockResolvedValue(idps); kcApiClientIdentityProvidersMock.create.mockResolvedValue({ id: '' }); kcApiClientIdentityProvidersMock.update.mockResolvedValue(); @@ -178,7 +173,7 @@ describe('KeycloakConfigurationService Unit', () => { expect(kcApiClientIdentityProvidersMock.update).toBeCalledTimes(1); }); it('should delete a new configuration in Keycloak', async () => { - systemOidcService.findAll.mockResolvedValue([]); + systemService.find.mockResolvedValue([]); const result = await service.configureIdentityProviders(); expect(result).toBe(1); @@ -210,7 +205,7 @@ describe('KeycloakConfigurationService Unit', () => { kcApiClientMock.find.mockResolvedValue([]); kcApiClientMock.create.mockResolvedValue({ id: 'new_client_id' }); kcApiClientMock.generateNewClientSecret.mockResolvedValue({ type: 'secret', value: 'generated_client_secret' }); - systemOidcService.findAll.mockResolvedValue([]); + systemService.find.mockResolvedValue([]); const response = { data: { token_endpoint: 'tokenEndpoint', diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts index 89389a5318d..73e3e3be837 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.ts @@ -5,8 +5,8 @@ import IdentityProviderMapperRepresentation from '@keycloak/keycloak-admin-clien import IdentityProviderRepresentation from '@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation'; import ProtocolMapperRepresentation from '@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation'; import { ServerConfig } from '@modules/server/server.config'; -import { OidcConfigDto } from '@modules/system/service'; -import { SystemOidcService } from '@modules/system/service/system-oidc.service'; +import { OidcConfig, SystemType } from '@modules/system/domain'; +import { SystemService } from '@modules/system/service'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; @@ -28,7 +28,7 @@ export class KeycloakConfigurationService { private readonly kcAdmin: KeycloakAdministrationService, private readonly configService: ConfigService, private readonly oidcIdentityProviderMapper: OidcIdentityProviderMapper, - private readonly systemOidcService: SystemOidcService + private readonly systemService: SystemService ) {} public async configureBrokerFlows(): Promise { @@ -129,7 +129,10 @@ export class KeycloakConfigurationService { let count = 0; const kc = await this.kcAdmin.callKcAdminClient(); const oldConfigs = await kc.identityProviders.find(); - const newConfigs = await this.systemOidcService.findAll(); + const oidcSystems = await this.systemService.find({ types: [SystemType.OIDC] }); + const newConfigs = oidcSystems + .map((entity) => entity.oidcConfig) + .filter((entity): entity is OidcConfig => entity !== undefined); const configureActions = this.selectConfigureAction(newConfigs, oldConfigs); // eslint-disable-next-line no-restricted-syntax for (const configureAction of configureActions) { @@ -188,10 +191,10 @@ export class KeycloakConfigurationService { * @param oldConfigs * @returns */ - private selectConfigureAction(newConfigs: OidcConfigDto[], oldConfigs: IdentityProviderRepresentation[]) { + private selectConfigureAction(newConfigs: OidcConfig[], oldConfigs: IdentityProviderRepresentation[]) { const result = [] as ( - | { action: ConfigureAction.CREATE; config: OidcConfigDto } - | { action: ConfigureAction.UPDATE; config: OidcConfigDto } + | { action: ConfigureAction.CREATE; config: OidcConfig } + | { action: ConfigureAction.UPDATE; config: OidcConfig } | { action: ConfigureAction.DELETE; alias: string } )[]; // updating or creating configs @@ -211,7 +214,7 @@ export class KeycloakConfigurationService { return result; } - private async createIdentityProvider(oidcConfig: OidcConfigDto): Promise { + private async createIdentityProvider(oidcConfig: OidcConfig): Promise { const kc = await this.kcAdmin.callKcAdminClient(); if (oidcConfig && oidcConfig?.idpHint) { await kc.identityProviders.create( @@ -221,7 +224,7 @@ export class KeycloakConfigurationService { } } - private async updateIdentityProvider(oidcConfig: OidcConfigDto): Promise { + private async updateIdentityProvider(oidcConfig: OidcConfig): Promise { const kc = await this.kcAdmin.callKcAdminClient(); if (oidcConfig && oidcConfig?.idpHint) { await kc.identityProviders.update( diff --git a/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.spec.ts b/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.spec.ts new file mode 100644 index 00000000000..35a0443f91c --- /dev/null +++ b/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.spec.ts @@ -0,0 +1,16 @@ +import { IDMLoginError } from './idm-login-error.loggable'; + +describe('IDMLoginError', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const err = new Error(); + const loggable = new IDMLoginError(err); + + expect(loggable.getLogMessage()).toStrictEqual({ + message: 'Error while trying to login via IDM', + stack: err.stack, + type: 'IDM_LOGIN_ERROR', + }); + }); + }); +}); diff --git a/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.ts b/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.ts new file mode 100644 index 00000000000..9c2096cd2e0 --- /dev/null +++ b/apps/server/src/infra/identity-management/keycloak/errors/idm-login-error.loggable.ts @@ -0,0 +1,13 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class IDMLoginError implements Loggable { + constructor(private readonly error: Error) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Error while trying to login via IDM', + stack: this.error.stack, + type: 'IDM_LOGIN_ERROR', + }; + } +} diff --git a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts index 8bd354ff379..acd84025ad8 100644 --- a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.spec.ts @@ -2,10 +2,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { DefaultEncryptionService, EncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; +import { Logger } from '@src/core/logger'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; import { KeycloakIdentityManagementOauthService } from './keycloak-identity-management-oauth.service'; @@ -14,7 +14,6 @@ describe('KeycloakIdentityManagementService', () => { let kcIdmOauthService: KeycloakIdentityManagementOauthService; let kcAdminServiceMock: DeepMocked; let httpServiceMock: DeepMocked; - let configServiceMock: DeepMocked; let oAuthEncryptionService: DeepMocked; const clientId = 'TheClientId'; @@ -33,8 +32,8 @@ describe('KeycloakIdentityManagementService', () => { useValue: createMock(), }, { - provide: ConfigService, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, { provide: DefaultEncryptionService, @@ -46,7 +45,6 @@ describe('KeycloakIdentityManagementService', () => { kcIdmOauthService = module.get(KeycloakIdentityManagementOauthService); kcAdminServiceMock = module.get(KeycloakAdministrationService); httpServiceMock = module.get(HttpService); - configServiceMock = module.get(ConfigService); }); afterEach(() => { @@ -57,7 +55,6 @@ describe('KeycloakIdentityManagementService', () => { const setupOauthConfigurationReturn = () => { oAuthEncryptionService.encrypt.mockImplementation((value: string) => `${value}_enc`); oAuthEncryptionService.decrypt.mockImplementation((value: string) => value.substring(0, -4)); - configServiceMock.get.mockReturnValue('testdomain'); kcAdminServiceMock.callKcAdminClient.mockResolvedValue({} as KeycloakAdminClient); kcAdminServiceMock.getClientId.mockReturnValueOnce(clientId); kcAdminServiceMock.getClientSecret.mockResolvedValueOnce(clientSecret); @@ -109,7 +106,7 @@ describe('KeycloakIdentityManagementService', () => { const ret = await kcIdmOauthService.getOauthConfig(); - expect(ret.redirectUri).toBe('https://testdomain/api/v3/sso/oauth/'); + expect(ret.redirectUri).toBe(''); }); it('should return the keycloak OAuth configuration from well-known', async () => { @@ -128,7 +125,6 @@ describe('KeycloakIdentityManagementService', () => { describe('when localhost is set as SC DOMAIN', () => { const setup = () => { setupOauthConfigurationReturn(); - configServiceMock.get.mockReturnValue('localhost'); }; it('should return the keycloak OAuth redirect URL for local development', async () => { @@ -136,7 +132,7 @@ describe('KeycloakIdentityManagementService', () => { const ret = await kcIdmOauthService.getOauthConfig(); - expect(ret.redirectUri).toBe('http://localhost:3030/api/v3/sso/oauth/'); + expect(ret.redirectUri).toBe(''); }); }); 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 b6458640c75..6310e50ab76 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,40 +1,38 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; -import { OauthConfigDto } from '@modules/system/service/dto'; +import { OauthConfig } from '@modules/system/domain'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; import qs from 'qs'; import { lastValueFrom } from 'rxjs'; import { IdentityManagementOauthService } from '../../identity-management-oauth.service'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; +import { IDMLoginError } from '../errors/idm-login-error.loggable'; @Injectable() export class KeycloakIdentityManagementOauthService extends IdentityManagementOauthService { - private _oauthConfigCache: OauthConfigDto | undefined; + private _oauthConfigCache: OauthConfig | undefined; constructor( private readonly kcAdminService: KeycloakAdministrationService, - private readonly configService: ConfigService, private readonly httpService: HttpService, - @Inject(DefaultEncryptionService) private readonly oAuthEncryptionService: EncryptionService + @Inject(DefaultEncryptionService) private readonly oAuthEncryptionService: EncryptionService, + private readonly logger: Logger ) { super(); } - async getOauthConfig(): Promise { + async getOauthConfig(): Promise { if (this._oauthConfigCache) { return this._oauthConfigCache; } const wellKnownUrl = this.kcAdminService.getWellKnownUrl(); const response = (await lastValueFrom(this.httpService.get>(wellKnownUrl))).data; - const scDomain = this.configService.get('SC_DOMAIN') || ''; - const redirectUri = - scDomain === 'localhost' ? 'http://localhost:3030/api/v3/sso/oauth/' : `https://${scDomain}/api/v3/sso/oauth/`; - this._oauthConfigCache = new OauthConfigDto({ + this._oauthConfigCache = new OauthConfig({ clientId: this.kcAdminService.getClientId(), clientSecret: this.oAuthEncryptionService.encrypt(await this.kcAdminService.getClientSecret()), provider: 'oauth', - redirectUri, + redirectUri: '', responseType: 'code', grantType: 'authorization_code', scope: 'openid profile email', @@ -59,15 +57,15 @@ export class KeycloakIdentityManagementOauthService extends IdentityManagementOa } async resourceOwnerPasswordGrant(username: string, password: string): Promise { - const { clientId, clientSecret, tokenEndpoint } = await this.getOauthConfig(); - const data = { - username, - password, - grant_type: 'password', - client_id: clientId, - client_secret: this.oAuthEncryptionService.decrypt(clientSecret), - }; try { + const { clientId, clientSecret, tokenEndpoint } = await this.getOauthConfig(); + const data = { + username, + password, + grant_type: 'password', + client_id: clientId, + client_secret: this.oAuthEncryptionService.decrypt(clientSecret), + }; const response = await lastValueFrom( this.httpService.request<{ access_token: string }>({ method: 'post', @@ -80,6 +78,8 @@ export class KeycloakIdentityManagementOauthService extends IdentityManagementOa ); return response.data.access_token; } catch (err) { + this.logger.warning(new IDMLoginError(err as Error)); + return undefined; } } diff --git a/apps/server/src/infra/schulconnex-client/index.ts b/apps/server/src/infra/schulconnex-client/index.ts index 8d08d2fa56f..bf68886a92a 100644 --- a/apps/server/src/infra/schulconnex-client/index.ts +++ b/apps/server/src/infra/schulconnex-client/index.ts @@ -1,21 +1,6 @@ export { SchulconnexRestClientOptions } from './schulconnex-rest-client-options'; export { SchulconnexClientModule } from './schulconnex-client.module'; export { SchulconnexRestClient } from './schulconnex-rest-client'; -export { - SchulconnexResponse, - SchulconnexRole, - SchulconnexGroupRole, - SchulconnexGroupType, - SchulconnexGruppenResponse, - SchulconnexResponseValidationGroups, - SchulconnexPersonResponse, - SchulconnexAnschriftResponse, - SchulconnexGruppenzugehoerigkeitResponse, - SchulconnexGruppeResponse, - SchulconnexNameResponse, - SchulconnexOrganisationResponse, - SchulconnexPersonenkontextResponse, - SchulconnexSonstigeGruppenzugehoerigeResponse, -} from './response'; +export * from './response'; export { schulconnexResponseFactory, schulconnexLizenzInfoResponseFactory } from './testing'; export { SchulconnexClientConfig } from './schulconnex-client-config'; diff --git a/apps/server/src/infra/schulconnex-client/response/index.ts b/apps/server/src/infra/schulconnex-client/response/index.ts index fed85ea47fa..d453e030115 100644 --- a/apps/server/src/infra/schulconnex-client/response/index.ts +++ b/apps/server/src/infra/schulconnex-client/response/index.ts @@ -14,4 +14,5 @@ export { SchulconnexAnschriftResponse } from './schulconnex-anschrift-response'; export { SchulconnexResponseValidationGroups } from './schulconnex-response-validation-groups'; export { SchulconnexErreichbarkeitenResponse } from './schulconnex-erreichbarkeiten-response'; export { SchulconnexCommunicationType } from './schulconnex-communication-type'; +export { SchulconnexLaufzeitResponse, lernperiodeFormat } from './schulconnex-laufzeit-response'; export * from './lizenz-info'; diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts index 4a9a754b71a..2533d306743 100644 --- a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts @@ -1,5 +1,7 @@ -import { IsEnum, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsEnum, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SchulconnexGroupType } from './schulconnex-group-type'; +import { SchulconnexLaufzeitResponse } from './schulconnex-laufzeit-response'; export class SchulconnexGruppeResponse { @IsString() @@ -10,4 +12,10 @@ export class SchulconnexGruppeResponse { @IsEnum(SchulconnexGroupType) typ!: SchulconnexGroupType; + + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => SchulconnexLaufzeitResponse) + laufzeit?: SchulconnexLaufzeitResponse; } diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-laufzeit-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-laufzeit-response.ts new file mode 100644 index 00000000000..96f39c61a50 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-laufzeit-response.ts @@ -0,0 +1,21 @@ +import { IsDateString, IsOptional, Matches } from 'class-validator'; + +export const lernperiodeFormat = /^(\d{4})(?:-([1-2]))?$/; + +export class SchulconnexLaufzeitResponse { + @IsOptional() + @IsDateString() + von?: string; + + @IsOptional() + @IsDateString() + bis?: string; + + @IsOptional() + @Matches(lernperiodeFormat) + vonlernperiode?: string; + + @IsOptional() + @Matches(lernperiodeFormat) + bislernperiode?: string; +} diff --git a/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts b/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts index 2cb7dcb5ece..c41e603e37e 100644 --- a/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts +++ b/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts @@ -38,6 +38,10 @@ export const schulconnexResponseFactory = Factory.define(() id: new UUID().toString(), bezeichnung: 'bezeichnung', typ: SchulconnexGroupType.CLASS, + laufzeit: { + vonlernperiode: '2024-1', + bislernperiode: '2024-2', + }, }, gruppenzugehoerigkeit: { rollen: [SchulconnexGroupRole.TEACHER], diff --git a/apps/server/src/migrations/mikro-orm/Migration20240627134214.ts b/apps/server/src/migrations/mikro-orm/Migration20240627134214.ts new file mode 100644 index 00000000000..95372879c47 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240627134214.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240627134214 extends Migration { + async up(): Promise { + await this.driver.aggregate('school-external-tools', [ + { $set: { isDeactivated: { $ifNull: ['$status.isDeactivated', false] } } }, + { $unset: 'status' }, + { $out: 'school-external-tools' }, + ]); + + console.info(`'status.isDeactivated' has moved to 'isDeactivated' for all school-external-tools`); + } + + async down(): Promise { + await this.driver.nativeUpdate( + 'school-external-tools', + { isDeactivated: true }, + { + $set: { + status: { + isOutdatedOnScopeSchool: false, + isDeactivated: true, + }, + }, + } + ); + + await this.driver.nativeUpdate( + 'school-external-tools', + {}, + { + $unset: { isDeactivated: '' }, + } + ); + + console.info(`All school-external-tools were reverted to using 'status'`); + } +} diff --git a/apps/server/src/modules/account/account.module.spec.ts b/apps/server/src/modules/account/account.module.spec.ts index ba7e80ec5c0..31a431eaf36 100644 --- a/apps/server/src/modules/account/account.module.spec.ts +++ b/apps/server/src/modules/account/account.module.spec.ts @@ -4,7 +4,6 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { AccountModule } from './account.module'; import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/micro-orm/mapper'; import { AccountService } from './domain/services/account.service'; -import { AccountValidationService } from './domain/services/account.validation.service'; describe('AccountModule', () => { let module: TestingModule; @@ -32,11 +31,6 @@ describe('AccountModule', () => { expect(accountService).toBeDefined(); }); - it('should have the account validation service defined', () => { - const accountValidationService = module.get(AccountValidationService); - expect(accountValidationService).toBeDefined(); - }); - describe('when FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED is enabled', () => { let moduleFeatureEnabled: TestingModule; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 4f7bd675c42..4acde7473fb 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -1,17 +1,17 @@ import { IdentityManagementModule } from '@infra/identity-management'; +import { SystemModule } from '@modules/system'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { LegacySystemRepo, UserRepo } from '@shared/repo'; import { CqrsModule } from '@nestjs/cqrs'; +import { UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger/logger.module'; import { AccountConfig } from './account-config'; -import { AccountRepo } from './repo/micro-orm/account.repo'; -import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/micro-orm/mapper'; import { AccountServiceDb } from './domain/services/account-db.service'; import { AccountServiceIdm } from './domain/services/account-idm.service'; import { AccountService } from './domain/services/account.service'; -import { AccountValidationService } from './domain/services/account.validation.service'; +import { AccountRepo } from './repo/micro-orm/account.repo'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/micro-orm/mapper'; function accountIdmToDtoMapperFactory(configService: ConfigService): AccountIdmToDoMapper { if (configService.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') === true) { @@ -21,21 +21,19 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { provide: ConfigService, useValue: createMock(), }, - { - provide: AccountValidationService, - useValue: createMock(), - }, { provide: AuthorizationService, useValue: createMock(), diff --git a/apps/server/src/modules/account/api/test/account.api.spec.ts b/apps/server/src/modules/account/api/test/account.api.spec.ts index e5ee1b94c2d..417e6a3a26b 100644 --- a/apps/server/src/modules/account/api/test/account.api.spec.ts +++ b/apps/server/src/modules/account/api/test/account.api.spec.ts @@ -1,10 +1,13 @@ +import { faker } from '@faker-js/faker'; import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; import { TestApiClient, cleanupCollections, roleFactory, schoolEntityFactory, userFactory } from '@shared/testing'; -import { ServerTestModule } from '@modules/server/server.module'; +import { AccountEntity } from '../../domain/entity/account.entity'; +import { accountFactory } from '../../testing'; import { AccountByIdBodyParams, AccountSearchQueryParams, @@ -12,8 +15,6 @@ import { PatchMyAccountParams, PatchMyPasswordParams, } from '../dto'; -import { AccountEntity } from '../../domain/entity/account.entity'; -import { accountFactory } from '../../testing'; describe('Account Controller (API)', () => { const basePath = '/account'; @@ -495,8 +496,8 @@ describe('Account Controller (API)', () => { const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); - const studentAccount = mapUserToAccount(studentUser); - const superheroAccount = mapUserToAccount(superheroUser); + const studentAccount = accountFactory.withUser(studentUser).build({ username: faker.internet.email() }); + const superheroAccount = accountFactory.withUser(superheroUser).build(); em.persist(school); em.persist([studentRoles, superheroRoles]); diff --git a/apps/server/src/modules/account/domain/account.ts b/apps/server/src/modules/account/domain/account.ts index 82153ffee4a..2b15037c12c 100644 --- a/apps/server/src/modules/account/domain/account.ts +++ b/apps/server/src/modules/account/domain/account.ts @@ -13,6 +13,7 @@ export interface AccountProps extends AuthorizableObject { password?: string; token?: string; credentialHash?: string; + lastLogin?: Date; lasttriedFailedLogin?: Date; expiresAt?: Date; activated?: boolean; @@ -73,6 +74,14 @@ export class Account extends DomainObject { return this.props.credentialHash; } + public get lastLogin(): Date | undefined { + return this.props.lastLogin; + } + + public set lastLogin(lastLogin: Date | undefined) { + this.props.lastLogin = lastLogin; + } + public get lasttriedFailedLogin(): Date | undefined { return this.props.lasttriedFailedLogin; } diff --git a/apps/server/src/modules/account/domain/entity/account.entity.ts b/apps/server/src/modules/account/domain/entity/account.entity.ts index 1c82e085054..736e8897431 100644 --- a/apps/server/src/modules/account/domain/entity/account.entity.ts +++ b/apps/server/src/modules/account/domain/entity/account.entity.ts @@ -26,6 +26,10 @@ export class AccountEntity extends BaseEntityWithTimestamps { @Property({ nullable: true }) systemId?: ObjectId; + @Property({ nullable: true }) + @Index() + lastLogin?: Date; + @Property({ nullable: true }) lasttriedFailedLogin?: Date; @@ -47,6 +51,7 @@ export class AccountEntity extends BaseEntityWithTimestamps { this.userId = props.userId; this.systemId = props.systemId; this.lasttriedFailedLogin = props.lasttriedFailedLogin; + this.lastLogin = props.lastLogin; this.expiresAt = props.expiresAt; this.activated = props.activated; this.deactivatedAt = props.deactivatedAt; diff --git a/apps/server/src/modules/account/domain/services/account-db.service.spec.ts b/apps/server/src/modules/account/domain/services/account-db.service.spec.ts index e39962db20b..4b5911267a8 100644 --- a/apps/server/src/modules/account/domain/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account-db.service.spec.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { IdentityManagementService } from '@infra/identity-management'; import { ObjectId } from '@mikro-orm/mongodb'; @@ -10,12 +11,12 @@ import { setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import bcrypt from 'bcryptjs'; import { v1 } from 'uuid'; -import { Account } from '../account'; import { AccountConfig } from '../../account-config'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; +import { accountDoFactory } from '../../testing'; +import { Account } from '../account'; import { AccountEntity } from '../entity/account.entity'; import { AccountServiceDb } from './account-db.service'; -import { accountDoFactory } from '../../testing'; describe('AccountDbService', () => { let module: TestingModule; @@ -713,27 +714,40 @@ describe('AccountDbService', () => { }); }); + describe('updateLastLogin', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const theNewDate = new Date(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount, theNewDate }; + }; + + it('should update last tried failed login', async () => { + const { mockTeacherAccount, theNewDate } = setup(); + + const ret = await accountService.updateLastLogin(mockTeacherAccount.id, theNewDate); + + expect(ret.lastLogin).toEqual(theNewDate); + }); + }); + describe('updateLastTriedFailedLogin', () => { - describe('when update last failed Login', () => { - const setup = () => { - const mockTeacherAccount = accountDoFactory.build(); - const theNewDate = new Date(); + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const theNewDate = new Date(); - accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.findById.mockResolvedValue(mockTeacherAccount); - return { mockTeacherAccount, theNewDate }; - }; + return { mockTeacherAccount, theNewDate }; + }; - it('should update last tried failed login', async () => { - const { mockTeacherAccount, theNewDate } = setup(); - const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); + it('should update last tried failed login', async () => { + const { mockTeacherAccount, theNewDate } = setup(); + const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccount.getProps(), - lasttriedFailedLogin: theNewDate, - }); - }); + expect(ret.lasttriedFailedLogin).toEqual(theNewDate); }); }); @@ -921,6 +935,7 @@ describe('AccountDbService', () => { }); }); }); + describe('findMany', () => { describe('when find many one time', () => { const setup = () => { @@ -955,4 +970,43 @@ describe('AccountDbService', () => { }); }); }); + + describe('isUniqueEmail', () => { + describe('when email is unique', () => { + const setup = () => { + const email = faker.internet.email(); + + accountRepo.findByUsername.mockResolvedValue(null); + + return { email }; + }; + + it('should return true', async () => { + const { email } = setup(); + + const result = await accountService.isUniqueEmail(email); + + expect(result).toBe(true); + }); + }); + + describe('when email is not unique', () => { + const setup = () => { + const email = faker.internet.email(); + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.findByUsername.mockResolvedValue(mockTeacherAccount); + + return { email, mockTeacherAccount }; + }; + + it('should return false', async () => { + const { email } = setup(); + + const result = await accountService.isUniqueEmail(email); + + expect(result).toBe(false); + }); + }); + }); }); diff --git a/apps/server/src/modules/account/domain/services/account-db.service.ts b/apps/server/src/modules/account/domain/services/account-db.service.ts index 0a2d9b69de8..36df25fb82b 100644 --- a/apps/server/src/modules/account/domain/services/account-db.service.ts +++ b/apps/server/src/modules/account/domain/services/account-db.service.ts @@ -9,14 +9,17 @@ import { AccountConfig } from '../../account-config'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; import { Account } from '../account'; import { AccountSave } from '../account-save'; +import { AbstractAccountService } from './account.service.abstract'; @Injectable() -export class AccountServiceDb { +export class AccountServiceDb extends AbstractAccountService { constructor( private readonly accountRepo: AccountRepo, private readonly idmService: IdentityManagementService, private readonly configService: ConfigService - ) {} + ) { + super(); + } async findById(id: EntityId): Promise { const internalId = await this.getInternalId(id); @@ -61,6 +64,14 @@ export class AccountServiceDb { return account; } + async updateLastLogin(accountId: EntityId, lastLogin: Date): Promise { + const internalId = await this.getInternalId(accountId); + const account = await this.accountRepo.findById(internalId); + account.lastLogin = lastLogin; + await this.accountRepo.save(account); + return account; + } + async updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise { const internalId = await this.getInternalId(accountId); const account = await this.accountRepo.findById(internalId); @@ -142,4 +153,11 @@ export class AccountServiceDb { return account; } + + public async isUniqueEmail(email: string): Promise { + const account = await this.accountRepo.findByUsername(email); + const isUnique = !account; + + return isUnique; + } } diff --git a/apps/server/src/modules/account/domain/services/account-idm.service.spec.ts b/apps/server/src/modules/account/domain/services/account-idm.service.spec.ts index 734143b331c..492b3269bf5 100644 --- a/apps/server/src/modules/account/domain/services/account-idm.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account-idm.service.spec.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { IdentityManagementOauthService, IdentityManagementService } from '@infra/identity-management'; @@ -7,8 +8,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { IdmAccount } from '@shared/domain/interface'; import { Logger } from '@src/core/logger'; -import { AccountConfig } from '../../account-config'; import { Account, AccountSave } from '..'; +import { AccountConfig } from '../../account-config'; import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../../repo/micro-orm/mapper'; import { AccountServiceIdm } from './account-idm.service'; @@ -532,4 +533,42 @@ describe('AccountIdmService', () => { await expect(accountIdmService.findMany(0, 0)).rejects.toThrow(NotImplementedException); }); }); + + describe('isUniqueEmail', () => { + describe('when email is unique', () => { + const setup = () => { + const email = faker.internet.email(); + + idmServiceMock.findAccountsByUsername.mockResolvedValue([[], 0]); + + return { email }; + }; + + it('should return true', async () => { + const { email } = setup(); + + const result = await accountIdmService.isUniqueEmail(email); + + expect(result).toBe(true); + }); + }); + + describe('when email is not unique', () => { + const setup = () => { + const email = faker.internet.email(); + + idmServiceMock.findAccountsByUsername.mockResolvedValue([[mockIdmAccount], 1]); + + return { email }; + }; + + it('should return false', async () => { + const { email } = setup(); + + const result = await accountIdmService.isUniqueEmail(email); + + expect(result).toBe(false); + }); + }); + }); }); diff --git a/apps/server/src/modules/account/domain/services/account-idm.service.ts b/apps/server/src/modules/account/domain/services/account-idm.service.ts index 766a159b0cb..c20b1ec7b14 100644 --- a/apps/server/src/modules/account/domain/services/account-idm.service.ts +++ b/apps/server/src/modules/account/domain/services/account-idm.service.ts @@ -191,4 +191,11 @@ export class AccountServiceIdm extends AbstractAccountService { } throw new EntityNotFoundError(`Account with id ${id.toString()} not found`); } + + public async isUniqueEmail(email: string): Promise { + const [accounts] = await this.identityManager.findAccountsByUsername(email, { exact: true }); + const isUniqueEmail = accounts.length === 0; + + return isUniqueEmail; + } } diff --git a/apps/server/src/modules/account/domain/services/account.service.abstract.ts b/apps/server/src/modules/account/domain/services/account.service.abstract.ts index 5876136170d..80a48cd718b 100644 --- a/apps/server/src/modules/account/domain/services/account.service.abstract.ts +++ b/apps/server/src/modules/account/domain/services/account.service.abstract.ts @@ -46,4 +46,6 @@ export abstract class AbstractAccountService { abstract deleteByUserId(userId: EntityId): Promise; abstract searchByUsernameExactMatch(userName: string): Promise>; + + abstract isUniqueEmail(email: string): Promise; } diff --git a/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts b/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts index 899f648cad3..9d75d0a0308 100644 --- a/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts @@ -10,18 +10,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { cleanupCollections } from '@shared/testing'; -import { v1 } from 'uuid'; import { Logger } from '@src/core/logger'; +import { KeycloakIdentityManagementService } from '@src/infra/identity-management/keycloak/service/keycloak-identity-management.service'; +import { v1 } from 'uuid'; import { Account, AccountSave } from '..'; -import { AccountEntity } from '../entity/account.entity'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../../repo/micro-orm/mapper'; +import { accountFactory } from '../../testing'; +import { AccountEntity } from '../entity/account.entity'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AccountService } from './account.service'; import { AbstractAccountService } from './account.service.abstract'; -import { AccountValidationService } from './account.validation.service'; -import { accountFactory } from '../../testing'; describe('AccountService Integration', () => { let module: TestingModule; @@ -93,7 +93,10 @@ describe('AccountService Integration', () => { AccountServiceDb, AccountRepo, UserRepo, - AccountValidationService, + { + provide: KeycloakIdentityManagementService, + useValue: createMock(), + }, { provide: AccountIdmToDoMapper, useValue: new AccountIdmToDoMapperDb(), diff --git a/apps/server/src/modules/account/domain/services/account.service.spec.ts b/apps/server/src/modules/account/domain/services/account.service.spec.ts index 4c4b4866776..77d9d7ebf35 100644 --- a/apps/server/src/modules/account/domain/services/account.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account.service.spec.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MikroORM } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; @@ -28,14 +29,13 @@ import { IdmCallbackLoggableException } from '../error'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AccountService } from './account.service'; -import { AccountValidationService } from './account.validation.service'; +import { AbstractAccountService } from './account.service.abstract'; describe('AccountService', () => { let module: TestingModule; let accountService: AccountService; let accountServiceIdm: DeepMocked; let accountServiceDb: DeepMocked; - let accountValidationService: DeepMocked; let configService: DeepMocked; let logger: DeepMocked; let userRepo: DeepMocked; @@ -48,7 +48,6 @@ describe('AccountService', () => { accountServiceDb, accountServiceIdm, configService, - accountValidationService, logger, userRepo, accountRepo, @@ -90,12 +89,6 @@ describe('AccountService', () => { provide: AccountRepo, useValue: createMock(), }, - { - provide: AccountValidationService, - useValue: { - isUniqueEmail: jest.fn().mockResolvedValue(true), - }, - }, { provide: UserRepo, useValue: createMock(), @@ -115,7 +108,6 @@ describe('AccountService', () => { accountServiceDb = module.get(AccountServiceDb); accountServiceIdm = module.get(AccountServiceIdm); accountService = module.get(AccountService); - accountValidationService = module.get(AccountValidationService); configService = module.get(ConfigService); logger = module.get(Logger); userRepo = module.get(UserRepo); @@ -366,7 +358,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); return spy; }; @@ -405,7 +397,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); }; it('should not throw an error', async () => { @@ -425,7 +417,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); }; it('should not throw an error', async () => { @@ -459,9 +451,9 @@ describe('AccountService', () => { }); }); - describe('When username already exists', () => { + describe('When username already exists in mongoDB', () => { const setup = () => { - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); }; it('should throw username already exists', async () => { @@ -473,6 +465,24 @@ describe('AccountService', () => { await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); }); }); + + describe('When username already exists in identity management', () => { + const setup = () => { + configService.get.mockReturnValue(true); + + accountServiceIdm.isUniqueEmail.mockResolvedValueOnce(false); + }; + + it('should throw username already exists', async () => { + setup(); + const params: AccountSave = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword_123', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + }); + }); + describe('When identity management is primary', () => { const setup = () => { configService.get.mockReturnValue(true); @@ -485,7 +495,7 @@ describe('AccountService', () => { accountServiceDb.save.mockResolvedValueOnce(account); accountServiceIdm.save.mockResolvedValueOnce(account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceIdm.isUniqueEmail.mockResolvedValueOnce(true); return { service: newAccountService(), account }; }; @@ -569,6 +579,16 @@ describe('AccountService', () => { }); }); + describe('updateLastLogin', () => { + it('should call updateLastLogin in accountServiceDb', async () => { + const someId = new ObjectId().toHexString(); + + await accountService.updateLastLogin(someId, new Date()); + + expect(accountServiceDb.updateLastLogin).toHaveBeenCalledTimes(1); + }); + }); + describe('updateLastTriedFailedLogin', () => { describe('When calling updateLastTriedFailedLogin in accountService', () => { it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { @@ -1052,7 +1072,7 @@ describe('AccountService', () => { accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); const spyAccountServiceSave = jest.spyOn(accountServiceDb, 'save'); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo, spyAccountServiceSave }; }; @@ -1088,7 +1108,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1120,7 +1140,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); @@ -1157,7 +1177,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); const userUpdateSpy = jest.spyOn(userRepo, 'save'); @@ -1192,7 +1212,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); const userUpdateSpy = jest.spyOn(userRepo, 'save'); @@ -1228,7 +1248,7 @@ describe('AccountService', () => { const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); accountServiceDb.validatePassword.mockResolvedValue(true); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1328,7 +1348,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1361,7 +1381,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockRejectedValueOnce(new ValidationError('fail to update')); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1431,7 +1451,7 @@ describe('AccountService', () => { Object.assign(mockStudentAccount, account); return Promise.resolve(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); }); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1493,7 +1513,7 @@ describe('AccountService', () => { userRepo.save.mockResolvedValue(); accountServiceDb.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1522,7 +1542,7 @@ describe('AccountService', () => { userRepo.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1583,7 +1603,7 @@ describe('AccountService', () => { const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); userRepo.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); return { mockStudentUser, mockStudentAccountDo, mockOtherTeacherAccount }; }; @@ -2073,4 +2093,24 @@ describe('AccountService', () => { }); }); }); + + describe('isUniqueEmail', () => { + describe('when checking if email is unique', () => { + const setup = () => { + const email = faker.internet.email(); + const accountImpl = Reflect.get(accountService, 'accountImpl') as DeepMocked; + const isUniqueEmailSpy = jest.spyOn(accountImpl, 'isUniqueEmail'); + + return { email, isUniqueEmailSpy }; + }; + + it('should call the underlying account service implementation', async () => { + const { email, isUniqueEmailSpy } = setup(); + + await accountService.isUniqueEmail(email); + + expect(isUniqueEmailSpy).toHaveBeenCalledWith(email); + }); + }); + }); }); diff --git a/apps/server/src/modules/account/domain/services/account.service.ts b/apps/server/src/modules/account/domain/services/account.service.ts index 37e6a6c8a81..877332b5a53 100644 --- a/apps/server/src/modules/account/domain/services/account.service.ts +++ b/apps/server/src/modules/account/domain/services/account.service.ts @@ -43,7 +43,6 @@ import { import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AbstractAccountService } from './account.service.abstract'; -import { AccountValidationService } from './account.validation.service'; type UserPreferences = { firstLogin: boolean; @@ -58,7 +57,6 @@ export class AccountService extends AbstractAccountService implements DeletionSe private readonly accountDb: AccountServiceDb, private readonly accountIdm: AccountServiceIdm, private readonly configService: ConfigService, - private readonly accountValidationService: AccountValidationService, private readonly logger: Logger, private readonly userRepo: UserRepo, private readonly accountRepo: AccountRepo, @@ -123,7 +121,7 @@ export class AccountService extends AbstractAccountService implements DeletionSe ): Promise { if (updateData.email && user.email !== updateData.email) { const newMail = updateData.email.toLowerCase(); - await this.checkUniqueEmail(account, user, newMail); + await this.checkUniqueEmail(newMail); user.email = newMail; accountSave.username = newMail; return true; @@ -168,7 +166,7 @@ export class AccountService extends AbstractAccountService implements DeletionSe } if (updateData.username !== undefined) { const newMail = updateData.username.toLowerCase(); - await this.checkUniqueEmail(targetAccount, targetUser, newMail); + await this.checkUniqueEmail(newMail); targetUser.email = newMail; targetAccount.username = newMail; updateUser = true; @@ -324,14 +322,7 @@ export class AccountService extends AbstractAccountService implements DeletionSe // trimPassword hook will be done by class-validator ✔ // local.hooks.hashPassword('password'), will be done by account service ✔ // checkUnique ✔ - if ( - !(await this.accountValidationService.isUniqueEmail( - accountSave.username, - accountSave.userId, - accountSave.id, - accountSave.systemId - )) - ) { + if (!(await this.isUniqueEmail(accountSave.username))) { throw new ValidationError('Username already exists'); } // removePassword hook is not implemented @@ -357,6 +348,10 @@ export class AccountService extends AbstractAccountService implements DeletionSe return new Account({ ...ret.getProps(), idmReferenceId: idmAccount?.idmReferenceId }); } + async updateLastLogin(accountId: string, lastLogin: Date): Promise { + await this.accountDb.updateLastLogin(accountId, lastLogin); + } + async updateLastTriedFailedLogin(accountId: string, lastTriedFailedLogin: Date): Promise { const ret = await this.accountDb.updateLastTriedFailedLogin(accountId, lastTriedFailedLogin); const idmAccount = await this.executeIdmMethod(async () => { @@ -435,8 +430,8 @@ export class AccountService extends AbstractAccountService implements DeletionSe return null; } - private async checkUniqueEmail(account: Account, user: User, email: string): Promise { - if (!(await this.accountValidationService.isUniqueEmail(email, user.id, account.id, account.systemId))) { + private async checkUniqueEmail(email: string): Promise { + if (!(await this.isUniqueEmail(email))) { throw new ValidationError(`The email address is already in use!`); } } @@ -446,4 +441,10 @@ export class AccountService extends AbstractAccountService implements DeletionSe return foundAccounts; } + + public async isUniqueEmail(email: string): Promise { + const isUniqueEmail = await this.accountImpl.isUniqueEmail(email); + + return isUniqueEmail; + } } diff --git a/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts b/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts deleted file mode 100644 index d376b8c81e3..00000000000 --- a/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Role } from '@shared/domain/entity'; -import { Permission, RoleName } from '@shared/domain/interface'; -import { UserRepo } from '@shared/repo'; -import { setupEntities, systemFactory, userFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountRepo } from '../../repo/micro-orm/account.repo'; -import { AccountValidationService } from './account.validation.service'; -import { accountDoFactory } from '../../testing'; - -describe('AccountValidationService', () => { - let module: TestingModule; - let accountValidationService: AccountValidationService; - - let userRepo: DeepMocked; - let accountRepo: DeepMocked; - - afterAll(async () => { - await module.close(); - }); - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountValidationService, - { - provide: AccountRepo, - useValue: createMock(), - }, - { - provide: UserRepo, - useValue: createMock(), - }, - ], - }).compile(); - - accountValidationService = module.get(AccountValidationService); - - userRepo = module.get(UserRepo); - accountRepo = module.get(AccountRepo); - - await setupEntities(); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('isUniqueEmail', () => { - describe('When new email is available', () => { - const setup = () => { - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - }; - it('should return true', async () => { - setup(); - - const res = await accountValidationService.isUniqueEmail('an@available.email'); - expect(res).toBe(true); - }); - }); - - describe('When new email is available', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - - return { mockStudentUser }; - }; - it('should return true and ignore current user', async () => { - const { mockStudentUser } = setup(); - const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - }); - - describe('When new email is available', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return true and ignore current users account', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockStudentAccount.username, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - }); - - describe('When new email already in use by another user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, - ], - }), - ], - }); - const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockAdminUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockAdminAccount], 1]); - - return { mockAdminUser, mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockAdminUser, mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockAdminUser.email, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by any user and system id is given', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockTeacherUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockTeacherAccount], 1]); - - return { mockTeacherAccount, mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockTeacherAccount, mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockTeacherAccount.username, - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by multiple users', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - const mockUsers = [mockTeacherUser, mockStudentUser, mockOtherTeacherUser]; - - userRepo.findByEmail.mockResolvedValueOnce(mockUsers); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - 'multiple@user.email', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by multiple accounts', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - const mockOtherTeacherAccount = accountDoFactory.build({ - userId: mockOtherTeacherUser.id, - }); - - const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockOtherTeacherAccount]; - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([mockAccounts, mockAccounts.length]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - 'multiple@account.username', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When its another system', () => { - const setup = () => { - const mockExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const externalSystemA = systemFactory.build(); - const externalSystemB = systemFactory.build(); - const mockExternalUserAccount = accountDoFactory.build({ - userId: mockExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemA.id, - }); - const mockOtherExternalUserAccount = accountDoFactory.build({ - userId: mockOtherExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemB.id, - }); - - userRepo.findByEmail.mockResolvedValueOnce([mockExternalUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockExternalUserAccount], 1]); - - return { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount }; - }; - it('should ignore existing username', async () => { - const { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockExternalUser.email, - mockExternalUser.id, - mockExternalUserAccount.id, - mockOtherExternalUserAccount.systemId?.toString() - ); - expect(res).toBe(true); - }); - }); - }); - - describe('isUniqueEmailForUser', () => { - describe('When its the email of the given user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findByUserId.mockResolvedValueOnce(mockStudentAccount); - - return { mockStudentUser }; - }; - it('should return true', async () => { - const { mockStudentUser } = setup(); - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - }); - - describe('When its not the given users email', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - const mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, - ], - }), - ], - }); - const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findByUserIdOrFail.mockResolvedValueOnce(mockAdminAccount); - - return { mockStudentUser, mockAdminUser }; - }; - it('should return false', async () => { - const { mockStudentUser, mockAdminUser } = setup(); - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); - expect(res).toBe(false); - }); - }); - }); - - describe('isUniqueEmailForAccount', () => { - describe('When its the email of the given user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findById.mockResolvedValueOnce(mockStudentAccount); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return true', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount( - mockStudentUser.email, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - }); - describe('When its not the given users email', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findById.mockResolvedValueOnce(mockTeacherAccount); - - return { mockStudentUser, mockTeacherAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockTeacherAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount( - mockStudentUser.email, - mockTeacherAccount.id - ); - expect(res).toBe(false); - }); - }); - - describe('When user is missing in account', () => { - const setup = () => { - const oprhanAccount = accountDoFactory.build({ - username: 'orphan@account', - userId: undefined, - systemId: new ObjectId().toHexString(), - }); - - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - accountRepo.findById.mockResolvedValueOnce(oprhanAccount); - - return { oprhanAccount }; - }; - it('should ignore missing user for given account', async () => { - const { oprhanAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); - expect(res).toBe(true); - }); - }); - }); -}); diff --git a/apps/server/src/modules/account/domain/services/account.validation.service.ts b/apps/server/src/modules/account/domain/services/account.validation.service.ts deleted file mode 100644 index 33ff32a15ad..00000000000 --- a/apps/server/src/modules/account/domain/services/account.validation.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { UserRepo } from '@shared/repo'; -import { AccountRepo } from '../../repo/micro-orm/account.repo'; - -@Injectable() -export class AccountValidationService { - constructor(private accountRepo: AccountRepo, private userRepo: UserRepo) {} - - async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { - const foundUsers = await this.userRepo.findByEmail(email); - const [accounts] = await this.accountRepo.searchByUsernameExactMatch(email); - const filteredAccounts = accounts.filter((foundAccount) => foundAccount.systemId === systemId); - - const multipleUsers = foundUsers.length > 1; - const multipleAccounts = filteredAccounts.length > 1; - // paranoid 'toString': legacy code may call userId or accountId as ObjectID - const oneUserWithoutGivenId = foundUsers.length === 1 && foundUsers[0].id.toString() !== userId?.toString(); - const oneAccountWithoutGivenId = - filteredAccounts.length === 1 && filteredAccounts[0].id.toString() !== accountId?.toString(); - - const isUnique = !(multipleUsers || multipleAccounts || oneUserWithoutGivenId || oneAccountWithoutGivenId); - - return isUnique; - } - - async isUniqueEmailForUser(email: string, userId: EntityId): Promise { - const account = await this.accountRepo.findByUserId(userId); - return this.isUniqueEmail(email, userId, account?.id, account?.systemId?.toString()); - } - - async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { - const account = await this.accountRepo.findById(accountId); - return this.isUniqueEmail(email, account.userId?.toString(), account.id, account.systemId?.toString()); - } -} diff --git a/apps/server/src/modules/account/repo/micro-orm/account.repo.integration.spec.ts b/apps/server/src/modules/account/repo/micro-orm/account.repo.integration.spec.ts index c6fc186c726..4fbdd62fe32 100644 --- a/apps/server/src/modules/account/repo/micro-orm/account.repo.integration.spec.ts +++ b/apps/server/src/modules/account/repo/micro-orm/account.repo.integration.spec.ts @@ -4,11 +4,11 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { User } from '@shared/domain/entity'; import { cleanupCollections, userFactory } from '@shared/testing'; -import { AccountRepo } from './account.repo'; import { AccountEntity } from '../../domain/entity/account.entity'; -import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; -import { AccountEntityToDoMapper } from './mapper'; import { accountDoFactory, accountFactory } from '../../testing'; +import { AccountRepo } from './account.repo'; +import { AccountEntityToDoMapper } from './mapper'; +import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; describe('account repo', () => { let module: TestingModule; @@ -115,6 +115,35 @@ describe('account repo', () => { }); }); + describe('findByUsername', () => { + describe('When username is given', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + + await em.persistAndFlush(accountToFind); + em.clear(); + + return accountToFind; + }; + + it('should find user by username', async () => { + const accountToFind = await setup(); + + const account = await repo.findByUsername(accountToFind.username); + + expect(account?.username).toEqual(accountToFind.username); + }); + }); + + describe('When username is not given', () => { + it('should return null', async () => { + const account = await repo.findByUsername(''); + + expect(account).toBeNull(); + }); + }); + }); + describe('findByUsernameAndSystemId', () => { describe('When username and systemId are given', () => { const setup = async () => { diff --git a/apps/server/src/modules/account/repo/micro-orm/account.repo.ts b/apps/server/src/modules/account/repo/micro-orm/account.repo.ts index d6566afa1c0..38c48f558f6 100644 --- a/apps/server/src/modules/account/repo/micro-orm/account.repo.ts +++ b/apps/server/src/modules/account/repo/micro-orm/account.repo.ts @@ -3,10 +3,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { SortOrder } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; -import { AccountEntity } from '../../domain/entity/account.entity'; -import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; import { Account } from '../../domain/account'; +import { AccountEntity } from '../../domain/entity/account.entity'; import { AccountEntityToDoMapper } from './mapper'; +import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; import { AccountScope } from './scope/account-scope'; @Injectable() @@ -66,6 +66,16 @@ export class AccountRepo { return AccountEntityToDoMapper.mapToDo(entity); } + public async findByUsername(username: string): Promise { + const entity = await this.em.findOne(AccountEntity, { username }); + + if (!entity) { + return null; + } + + return AccountEntityToDoMapper.mapToDo(entity); + } + public async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { const entity = await this.em.findOne(AccountEntity, { username, systemId: new ObjectId(systemId) }); diff --git a/apps/server/src/modules/account/repo/micro-orm/mapper/account-do-to-entity.mapper.ts b/apps/server/src/modules/account/repo/micro-orm/mapper/account-do-to-entity.mapper.ts index 173e7e98d72..bf2bd4f5b47 100644 --- a/apps/server/src/modules/account/repo/micro-orm/mapper/account-do-to-entity.mapper.ts +++ b/apps/server/src/modules/account/repo/micro-orm/mapper/account-do-to-entity.mapper.ts @@ -10,6 +10,7 @@ export class AccountDoToEntityMapper { activated: account.activated, credentialHash: account.credentialHash, expiresAt: account.expiresAt, + lastLogin: account.lastLogin, lasttriedFailedLogin: account.lasttriedFailedLogin, password: account.password, systemId: account.systemId ? new ObjectId(account.systemId) : undefined, diff --git a/apps/server/src/modules/account/repo/micro-orm/mapper/account-entity-to-do.mapper.ts b/apps/server/src/modules/account/repo/micro-orm/mapper/account-entity-to-do.mapper.ts index 59b514840cf..000ece5001a 100644 --- a/apps/server/src/modules/account/repo/micro-orm/mapper/account-entity-to-do.mapper.ts +++ b/apps/server/src/modules/account/repo/micro-orm/mapper/account-entity-to-do.mapper.ts @@ -13,6 +13,7 @@ export class AccountEntityToDoMapper { activated: account.activated, credentialHash: account.credentialHash, expiresAt: account.expiresAt, + lastLogin: account.lastLogin, lasttriedFailedLogin: account.lasttriedFailedLogin, password: account.password, systemId: account.systemId?.toString(), diff --git a/apps/server/src/modules/account/testing/account.factory.ts b/apps/server/src/modules/account/testing/account.factory.ts index 8c8177f1a9e..0cfa746e4cb 100644 --- a/apps/server/src/modules/account/testing/account.factory.ts +++ b/apps/server/src/modules/account/testing/account.factory.ts @@ -3,9 +3,9 @@ import { User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DeepPartial } from 'fishery'; -import { AccountEntity, IdmAccountProperties } from '@src/modules/account/domain/entity/account.entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { AccountEntity, IdmAccountProperties } from '@src/modules/account/domain/entity/account.entity'; +import { DeepPartial } from 'fishery'; export const defaultTestPassword = 'DummyPasswd!1'; export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; @@ -58,7 +58,7 @@ class AccountFactory extends BaseFactory { // !!! important username should not be contain a space !!! export const accountFactory = AccountFactory.define(AccountEntity, ({ sequence }) => { return { - username: `account${sequence}`, + username: `account#${sequence}@example.tld`, password: defaultTestPasswordHash, userId: new ObjectId(), }; diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index 48eeadf5c8a..588d4662c06 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -7,19 +7,19 @@ 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 { LegacySchoolRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { Algorithm, SignOptions } from 'jsonwebtoken'; import { jwtConstants } from './constants'; +import { JwtValidationAdapter } from './helper/jwt-validation.adapter'; import { AuthenticationService } from './services/authentication.service'; import { LdapService } from './services/ldap.service'; -import { JwtValidationAdapter } from './helper/jwt-validation.adapter'; import { JwtStrategy } from './strategy/jwt.strategy'; import { LdapStrategy } from './strategy/ldap.strategy'; import { LocalStrategy } from './strategy/local.strategy'; import { Oauth2Strategy } from './strategy/oauth2.strategy'; -import { XApiKeyStrategy } from './strategy/x-api-key.strategy'; import { WsJwtStrategy } from './strategy/ws-jwt.strategy'; +import { XApiKeyStrategy } from './strategy/x-api-key.strategy'; // values copied from Algorithm definition. Type does not exist at runtime and can't be checked anymore otherwise const algorithms = [ @@ -72,7 +72,6 @@ const jwtModuleOptions: JwtModuleOptions = { WsJwtStrategy, JwtValidationAdapter, UserRepo, - 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 7adbd663e87..65d284f938d 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -1,9 +1,10 @@ import { EntityManager } from '@mikro-orm/core'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server/server.module'; +import { SystemEntity } from '@modules/system/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { roleFactory, schoolEntityFactory, systemEntityFactory, userFactory } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; diff --git a/apps/server/src/modules/authentication/decorator/auth.decorator.ts b/apps/server/src/modules/authentication/decorator/auth.decorator.ts index 0bcfb55abc4..583799977f2 100644 --- a/apps/server/src/modules/authentication/decorator/auth.decorator.ts +++ b/apps/server/src/modules/authentication/decorator/auth.decorator.ts @@ -8,10 +8,9 @@ import { } from '@nestjs/common'; import { ApiBearerAuth } from '@nestjs/swagger'; import { Request } from 'express'; -import { ExtractJwt } from 'passport-jwt'; +import { extractJwtFromHeader } from '@shared/common'; import { JwtAuthGuard } from '../guard/jwt-auth.guard'; import { ICurrentUser, isICurrentUser } from '../interface/user'; -import { JwtExtractor } from '../helper/jwt-extractor'; const STRATEGIES = ['jwt'] as const; type Strategies = typeof STRATEGIES; @@ -56,9 +55,8 @@ export const CurrentUser = createParamDecorator((_, * @requires Authenticated */ export const JWT = createParamDecorator((_, ctx: ExecutionContext) => { - const getJWT = ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), JwtExtractor.fromCookie('jwt')]); const req: Request = ctx.switchToHttp().getRequest(); - const jwt = getJWT(req) || req.headers.authorization; + const jwt = extractJwtFromHeader(req) || req.headers.authorization; if (!jwt) { throw new UnauthorizedException('Authentication is required.'); diff --git a/apps/server/src/modules/authentication/services/authentication.service.spec.ts b/apps/server/src/modules/authentication/services/authentication.service.spec.ts index 51f10a7109a..32d6850f243 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.spec.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.spec.ts @@ -179,6 +179,14 @@ describe('AuthenticationService', () => { }); }); + describe('updateLastLogin', () => { + it('should call accountService to update last login', async () => { + await authenticationService.updateLastLogin('mockAccountId'); + + expect(accountService.updateLastLogin).toHaveBeenCalledWith('mockAccountId', expect.any(Date)); + }); + }); + describe('normalizeUsername', () => { describe('when a username is entered', () => { it('should trim username', () => { diff --git a/apps/server/src/modules/authentication/services/authentication.service.ts b/apps/server/src/modules/authentication/services/authentication.service.ts index 4a2b816b1e9..b2ecb5f4609 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.ts @@ -44,7 +44,7 @@ export class AuthenticationService { async generateJwt(user: CreateJwtPayload): Promise { const jti = randomUUID(); - const result: LoginDto = new LoginDto({ + const result = new LoginDto({ accessToken: this.jwtService.sign(user, { subject: user.accountId, jwtid: jti, @@ -79,6 +79,10 @@ export class AuthenticationService { } } + async updateLastLogin(accountId: string): Promise { + await this.accountService.updateLastLogin(accountId, new Date()); + } + async updateLastTriedFailedLogin(id: string): Promise { await this.accountService.updateLastTriedFailedLogin(id, new Date()); } 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 9fce46f1751..54c9d3ad93b 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.spec.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.spec.ts @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; +import { System } from '@modules/system'; import { Test, TestingModule } from '@nestjs/testing'; -import { SystemEntity } from '@shared/domain/entity'; -import { systemEntityFactory } from '@shared/testing'; +import { systemFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { LdapUserCouldNotBeAuthenticatedLoggableException } from '../loggable'; 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 = systemEntityFactory.withLdapConfig().buildWithId(); + const system: System = systemFactory.withLdapConfig().build(); 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 = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build(); 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 UserCouldNotAuthenticateLoggableException', async () => { - const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); + const system: System = systemFactory.withLdapConfig().build(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( LdapUserCouldNotBeAuthenticatedLoggableException ); diff --git a/apps/server/src/modules/authentication/services/ldap.service.ts b/apps/server/src/modules/authentication/services/ldap.service.ts index 3436d4e76e0..fc56ad12325 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.ts @@ -1,9 +1,9 @@ +import type { System } from '@modules/system'; import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { SystemEntity } from '@shared/domain/entity'; import { Logger } from '@src/core/logger'; import { Client, createClient } from 'ldapjs'; import { LdapConnectionError } from '../errors/ldap-connection.error'; -import { UserAuthenticatedLoggable, LdapUserCouldNotBeAuthenticatedLoggableException } from '../loggable'; +import { LdapUserCouldNotBeAuthenticatedLoggableException, UserAuthenticatedLoggable } from '../loggable'; @Injectable() export class LdapService { @@ -11,7 +11,7 @@ export class LdapService { this.logger.setContext(LdapService.name); } - async checkLdapCredentials(system: SystemEntity, username: string, password: string): Promise { + async checkLdapCredentials(system: System, username: string, password: string): Promise { const connection = await this.connect(system, username, password); if (connection.connected) { connection.unbind(); @@ -20,7 +20,7 @@ export class LdapService { throw new UnauthorizedException('User could not authenticate'); } - private connect(system: SystemEntity, username: string, password: string): Promise { + private connect(system: System, username: string, password: string): Promise { const { ldapConfig } = system; if (!ldapConfig) { throw Error(`no LDAP config found in system ${system.id}`); diff --git a/apps/server/src/modules/authentication/strategy/jwt.strategy.ts b/apps/server/src/modules/authentication/strategy/jwt.strategy.ts index 0dbe9e39bf4..84014531093 100644 --- a/apps/server/src/modules/authentication/strategy/jwt.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/jwt.strategy.ts @@ -1,21 +1,18 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Strategy } from 'passport-jwt'; +import { extractJwtFromHeader } from '@shared/common'; import { jwtConstants } from '../constants'; import { ICurrentUser } from '../interface'; import { JwtPayload } from '../interface/jwt-payload'; import { CurrentUserMapper } from '../mapper'; -import { JwtExtractor } from '../helper/jwt-extractor'; import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private readonly jwtValidationAdapter: JwtValidationAdapter) { super({ - jwtFromRequest: ExtractJwt.fromExtractors([ - ExtractJwt.fromAuthHeaderAsBearerToken(), - JwtExtractor.fromCookie('jwt'), - ]), + jwtFromRequest: extractJwtFromHeader, ignoreExpiration: false, secretOrKey: jwtConstants.secret, ...jwtConstants.jwtOptions, 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 b75653ee7ff..0e537c2ef56 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -1,20 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Account } from '@modules/account'; +import { System, SystemService } from '@modules/system'; import { UnauthorizedException } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { SystemEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; -import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; - -import { - legacySchoolDoFactory, - schoolEntityFactory, - setupEntities, - systemEntityFactory, - userFactory, -} from '@shared/testing'; +import { LegacySchoolRepo, UserRepo } from '@shared/repo'; +import { legacySchoolDoFactory, schoolEntityFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { accountDoFactory, defaultTestPassword, defaultTestPasswordHash } from '@src/modules/account/testing'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; @@ -31,7 +25,7 @@ describe('LdapStrategy', () => { let schoolRepoMock: DeepMocked; let authenticationServiceMock: DeepMocked; let ldapServiceMock: DeepMocked; - let systemRepo: DeepMocked; + let systemService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -57,8 +51,8 @@ describe('LdapStrategy', () => { useValue: createMock(), }, { - provide: LegacySystemRepo, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: Logger, @@ -72,7 +66,7 @@ describe('LdapStrategy', () => { schoolRepoMock = module.get(LegacySchoolRepo); userRepoMock = module.get(UserRepo); ldapServiceMock = module.get(LdapService); - systemRepo = module.get(LegacySystemRepo); + systemService = module.get(SystemService); }); afterAll(async () => { @@ -88,7 +82,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: undefined }); @@ -110,7 +104,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -135,7 +129,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -157,7 +151,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -182,7 +176,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -204,7 +198,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -229,7 +223,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -251,7 +245,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -276,7 +270,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -298,7 +292,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -328,7 +322,7 @@ describe('LdapStrategy', () => { const error = new Error('error'); const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: System = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).build(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -350,7 +344,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -383,7 +377,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); + const system: System = systemFactory.withLdapConfig().build(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) @@ -410,7 +404,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockResolvedValue(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); authenticationServiceMock.normalizePassword.mockReturnValue(defaultTestPassword); @@ -446,7 +440,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); + const system: System = systemFactory.withLdapConfig().build(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) @@ -473,7 +467,7 @@ describe('LdapStrategy', () => { }, }; - systemRepo.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); authenticationServiceMock.loadAccount.mockRejectedValueOnce(new UnauthorizedException()); authenticationServiceMock.loadAccount.mockResolvedValueOnce(account); authenticationServiceMock.normalizeUsername.mockReturnValue(username); diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index d732d39d25c..e922b90c13e 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -1,9 +1,10 @@ import { Account } from '@modules/account'; +import { System, SystemService } from '@modules/system'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { SystemEntity, User } from '@shared/domain/entity'; -import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { User } from '@shared/domain/entity'; +import { LegacySchoolRepo, UserRepo } from '@shared/repo'; import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; import { Strategy } from 'passport-custom'; @@ -16,7 +17,7 @@ import { LdapService } from '../services/ldap.service'; @Injectable() export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { constructor( - private readonly systemRepo: LegacySystemRepo, + private readonly systemService: SystemService, private readonly schoolRepo: LegacySchoolRepo, private readonly ldapService: LdapService, private readonly authenticationService: AuthenticationService, @@ -29,7 +30,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { async validate(request: { body: LdapAuthorizationBodyParams }): Promise { const { username, password, systemId, schoolId } = this.extractParamsFromRequest(request); - const system: SystemEntity = await this.systemRepo.findById(systemId); + const system: System = await this.systemService.findByIdOrFail(systemId); const school: LegacySchoolDo = await this.schoolRepo.findById(schoolId); if (!school.systems || !school.systems.includes(systemId)) { @@ -70,12 +71,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { return value; } - private async checkCredentials( - account: Account, - system: SystemEntity, - ldapDn: string, - password: string - ): Promise { + private async checkCredentials(account: Account, system: System, ldapDn: string, password: string): Promise { try { await this.ldapService.checkLdapCredentials(system, ldapDn, password); } catch (error) { diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.ts b/apps/server/src/modules/authentication/strategy/local.strategy.ts index 3e762219f7d..30e3f191b2c 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.ts @@ -3,7 +3,7 @@ import { Account } from '@modules/account'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; -import { GuardAgainst } from '@shared/common/utils/guard-against'; +import { TypeGuard } from '@shared/common'; import { UserRepo } from '@shared/repo'; import bcrypt from 'bcryptjs'; import { Strategy } from 'passport-local'; @@ -28,13 +28,13 @@ export class LocalStrategy extends PassportStrategy(Strategy) { if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED')) { const jwt = await this.idmOauthService.resourceOwnerPasswordGrant(username, password); - GuardAgainst.nullOrUndefined(jwt, new UnauthorizedException()); + TypeGuard.checkNotNullOrUndefined(jwt, new UnauthorizedException()); } else { - const accountPassword = GuardAgainst.nullOrUndefined(account.password, new UnauthorizedException()); + const accountPassword = TypeGuard.checkNotNullOrUndefined(account.password, new UnauthorizedException()); await this.checkCredentials(password, accountPassword, account); } - const accountUserId = GuardAgainst.nullOrUndefined( + const accountUserId = TypeGuard.checkNotNullOrUndefined( account.userId, new Error(`login failing, because account ${account.id} has no userId`) ); @@ -44,8 +44,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) { } private cleanupInput(username?: string, password?: string): { username: string; password: string } { - username = GuardAgainst.nullOrUndefined(username, new UnauthorizedException()); - password = GuardAgainst.nullOrUndefined(password, new UnauthorizedException()); + username = TypeGuard.checkNotNullOrUndefined(username, new UnauthorizedException()); + password = TypeGuard.checkNotNullOrUndefined(password, new UnauthorizedException()); username = this.authenticationService.normalizeUsername(username); password = this.authenticationService.normalizePassword(password); return { username, password }; diff --git a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts b/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts index ef9bf54b67c..ea76a267da0 100644 --- a/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ws-jwt.strategy.ts @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { WsException } from '@nestjs/websockets'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { JwtExtractor } from '@shared/common'; import { jwtConstants } from '../constants'; import { ICurrentUser } from '../interface'; import { JwtPayload } from '../interface/jwt-payload'; import { CurrentUserMapper } from '../mapper'; -import { JwtExtractor } from '../helper/jwt-extractor'; import { JwtValidationAdapter } from '../helper/jwt-validation.adapter'; @Injectable() diff --git a/apps/server/src/modules/authentication/uc/login.uc.spec.ts b/apps/server/src/modules/authentication/uc/login.uc.spec.ts index c0f1d924876..4b0d356402a 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.spec.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.spec.ts @@ -63,6 +63,14 @@ describe('LoginUc', () => { }); }); + it('should call updateLastLogin', async () => { + const { userInfo } = setup(); + + await loginUc.getLoginData(userInfo); + + expect(authenticationService.updateLastLogin).toHaveBeenCalledWith(userInfo.accountId); + }); + it('should return a loginDto', async () => { const { userInfo, loginDto } = setup(); diff --git a/apps/server/src/modules/authentication/uc/login.uc.ts b/apps/server/src/modules/authentication/uc/login.uc.ts index 80ab89ca49a..a676e0d79d3 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.ts @@ -14,6 +14,8 @@ export class LoginUc { const accessTokenDto: LoginDto = await this.authService.generateJwt(createJwtPayload); + await this.authService.updateLastLogin(userInfo.accountId); + const loginDto: LoginDto = new LoginDto({ accessToken: accessTokenDto.accessToken, }); diff --git a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts index 0d937c165a2..1c7e93c3ad4 100644 --- a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts @@ -1,8 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { SchoolSystemOptions } from '@modules/legacy-school'; +import { SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { schoolEntityFactory, diff --git a/apps/server/src/modules/authorization/domain/rules/school.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school.rule.spec.ts index 07758a92d01..9b1ac7446c6 100644 --- a/apps/server/src/modules/authorization/domain/rules/school.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/school.rule.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { schoolFactory } from '@modules/school/testing/school.factory'; import { Test, TestingModule } from '@nestjs/testing'; -import { setupEntities, userFactory } from '@shared/testing'; +import { schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from '../mapper'; import { AuthorizationHelper } from '../service/authorization.helper'; import { SchoolRule } from './school.rule'; @@ -23,7 +23,7 @@ describe('SchoolRule', () => { const setupSchoolAndUser = () => { const school = schoolFactory.build(); - const user = userFactory.build({ school }); + const user = userFactory.build({ school: schoolEntityFactory.buildWithId(undefined, school.id) }); return { school, user }; }; 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 index 8fb4d0173ba..04390c64e48 100644 --- a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts @@ -1,7 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { System } from '@modules/system'; +import { SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { schoolEntityFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from '../mapper'; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 3e7d5cf3c49..884db4ee05a 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -3,7 +3,6 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { TldrawClientModule } from '@modules/tldraw-client'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; -import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { UserModule } from '@modules/user'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; @@ -21,14 +20,14 @@ import { UserDeletedEventHandlerService, } from './service'; import { + BoardContextService, BoardNodeCopyService, + BoardNodeDeleteHooksService, ColumnBoardCopyService, ColumnBoardLinkService, ColumnBoardReferenceService, ColumnBoardTitleService, ContentElementUpdateService, - BoardNodeDeleteHooksService, - BoardContextService, } from './service/internal'; @Module({ @@ -39,7 +38,6 @@ import { UserModule, ContextExternalToolModule, HttpModule, - ToolConfigModule, TldrawClientModule, CqrsModule, CollaborativeTextEditorModule, diff --git a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts index a647be3ef08..c9a6cc7163b 100644 --- a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts +++ b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts @@ -10,6 +10,7 @@ import { WsException, } from '@nestjs/websockets'; import { Server } from 'socket.io'; +import { EntityId } from '@shared/domain/types'; import { BoardResponseMapper, CardResponseMapper, @@ -18,29 +19,27 @@ import { } from '../controller/mapper'; import { MetricsService } from '../metrics/metrics.service'; import { TrackExecutionTime } from '../metrics/track-execution-time.decorator'; -import { BoardNodeAuthorizableService } from '../service'; import { BoardUc, CardUc, ColumnUc, ElementUc } from '../uc'; -import { - CreateCardMessageParams, - DeleteColumnMessageParams, - MoveCardMessageParams, - UpdateColumnTitleMessageParams, -} from './dto'; -import BoardCollaborationConfiguration from './dto/board-collaboration-config'; +import { CreateCardMessageParams } from './dto/create-card.message.param'; import { CreateColumnMessageParams } from './dto/create-column.message.param'; import { CreateContentElementMessageParams } from './dto/create-content-element.message.param'; import { DeleteBoardMessageParams } from './dto/delete-board.message.param'; import { DeleteCardMessageParams } from './dto/delete-card.message.param'; import { DeleteContentElementMessageParams } from './dto/delete-content-element.message.param'; +import { DeleteColumnMessageParams } from './dto/delete-column.message.param'; import { FetchBoardMessageParams } from './dto/fetch-board.message.param'; import { FetchCardsMessageParams } from './dto/fetch-cards.message.param'; +import { MoveCardMessageParams } from './dto/move-card.message.param'; import { MoveColumnMessageParams } from './dto/move-column.message.param'; import { MoveContentElementMessageParams } from './dto/move-content-element.message.param'; import { UpdateBoardTitleMessageParams } from './dto/update-board-title.message.param'; import { UpdateBoardVisibilityMessageParams } from './dto/update-board-visibility.message.param'; import { UpdateCardHeightMessageParams } from './dto/update-card-height.message.param'; import { UpdateCardTitleMessageParams } from './dto/update-card-title.message.param'; +import { UpdateColumnTitleMessageParams } from './dto/update-column-title.message.param'; import { UpdateContentElementMessageParams } from './dto/update-content-element.message.param'; +import BoardCollaborationConfiguration from './dto/board-collaboration-config'; +import { AnyBoardNode } from '../domain'; @UsePipes(new WsValidationPipe()) @WebSocketGateway(BoardCollaborationConfiguration.websocket) @@ -56,13 +55,16 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { private readonly columnUc: ColumnUc, private readonly cardUc: CardUc, private readonly elementUc: ElementUc, - private readonly metricsService: MetricsService, - private readonly authorizableService: BoardNodeAuthorizableService // to be removed + private readonly metricsService: MetricsService ) {} trackExecutionTime(methodName: string, executionTimeMs: number) { if (this.metricsService) { this.metricsService.setExecutionTime(methodName, executionTimeMs); + this.metricsService.incrementActionCount(methodName); + this.metricsService.incrementActionGauge(methodName); + this.metricsService.incrementActionCount('all'); + this.metricsService.incrementActionGauge('all'); } } @@ -88,12 +90,11 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @SubscribeMessage('delete-board-request') @UseRequestContext() async deleteBoard(socket: Socket, data: DeleteBoardMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'delete-board' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'delete-board' }); const { userId } = this.getCurrentUser(socket); try { - await this.boardUc.deleteBoard(userId, data.boardId); - - await emitter.emitToClientAndRoom(data); + const board = await this.boardUc.deleteBoard(userId, data.boardId); + emitter.emitToClientAndRoom(data, board); } catch (err) { emitter.emitFailure(data); } @@ -104,12 +105,11 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async updateBoardTitle(socket: Socket, data: UpdateBoardTitleMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'update-board-title' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-board-title' }); const { userId } = this.getCurrentUser(socket); try { - await this.boardUc.updateBoardTitle(userId, data.boardId, data.newTitle); - - await emitter.emitToClientAndRoom(data); + const board = await this.boardUc.updateBoardTitle(userId, data.boardId, data.newTitle); + emitter.emitToClientAndRoom(data, board); } catch (err) { emitter.emitFailure(data); } @@ -120,12 +120,11 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async updateCardTitle(socket: Socket, data: UpdateCardTitleMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'update-card-title' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-card-title' }); const { userId } = this.getCurrentUser(socket); try { - await this.cardUc.updateCardTitle(userId, data.cardId, data.newTitle); - - await emitter.emitToClientAndRoom(data); + const card = await this.cardUc.updateCardTitle(userId, data.cardId, data.newTitle); + emitter.emitToClientAndRoom(data, card); } catch (err) { emitter.emitFailure(data); } @@ -133,14 +132,14 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('update-card-height-request') + @TrackExecutionTime() @UseRequestContext() async updateCardHeight(socket: Socket, data: UpdateCardHeightMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'update-card-height' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-card-height' }); const { userId } = this.getCurrentUser(socket); try { - await this.cardUc.updateCardHeight(userId, data.cardId, data.newHeight); - - await emitter.emitToClientAndRoom(data); + const card = await this.cardUc.updateCardHeight(userId, data.cardId, data.newHeight); + emitter.emitToClientAndRoom(data, card); } catch (err) { emitter.emitFailure(data); } @@ -148,14 +147,14 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('delete-card-request') + @TrackExecutionTime() @UseRequestContext() async deleteCard(socket: Socket, data: DeleteCardMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'delete-card' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'delete-card' }); const { userId } = this.getCurrentUser(socket); try { - await this.cardUc.deleteCard(userId, data.cardId); - - await emitter.emitToClientAndRoom(data); + const rootId = await this.cardUc.deleteCard(userId, data.cardId); + emitter.emitToClientAndRoom(data, rootId); } catch (err) { emitter.emitFailure(data); } @@ -166,10 +165,10 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async createCard(socket: Socket, data: CreateCardMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.columnId, action: 'create-card' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'create-card' }); const { userId } = this.getCurrentUser(socket); try { - const card = await this.columnUc.createCard(userId, data.columnId); + const card = await this.columnUc.createCard(userId, data.columnId, data.requiredEmptyElements); const newCard = CardResponseMapper.mapToResponse(card); const responsePayload = { @@ -177,7 +176,7 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { newCard, }; - await emitter.emitToClientAndRoom(responsePayload); + emitter.emitToClientAndRoom(responsePayload, card); } catch (err) { emitter.emitFailure(data); } @@ -185,9 +184,10 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('create-column-request') + @TrackExecutionTime() @UseRequestContext() async createColumn(socket: Socket, data: CreateColumnMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'create-column' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'create-column' }); const { userId } = this.getCurrentUser(socket); try { const column = await this.boardUc.createColumn(userId, data.boardId); @@ -197,7 +197,7 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { ...data, newColumn, }; - await emitter.emitToClientAndRoom(responsePayload); + emitter.emitToClientAndRoom(responsePayload, column); // payload needs to be returned to allow the client to do sequential operation // of createColumn and move the card into that column @@ -212,13 +212,13 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async fetchBoard(socket: Socket, data: FetchBoardMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'fetch-board' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'fetch-board' }); const { userId } = this.getCurrentUser(socket); try { const board = await this.boardUc.findBoard(userId, data.boardId); - const responsePayload = BoardResponseMapper.mapToResponse(board); - await emitter.emitToClient(responsePayload); + await emitter.joinRoom(board); + emitter.emitSuccess(responsePayload); } catch (err) { emitter.emitFailure(data); } @@ -226,14 +226,14 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('move-card-request') + @TrackExecutionTime() @UseRequestContext() async moveCard(socket: Socket, data: MoveCardMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'move-card' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'move-card' }); const { userId } = this.getCurrentUser(socket); try { - await this.columnUc.moveCard(userId, data.cardId, data.toColumnId, data.newIndex); - - await emitter.emitToClientAndRoom(data); + const card = await this.columnUc.moveCard(userId, data.cardId, data.toColumnId, data.newIndex); + emitter.emitToClientAndRoom(data, card); } catch (err) { emitter.emitFailure(data); } @@ -241,14 +241,19 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('move-column-request') + @TrackExecutionTime() @UseRequestContext() async moveColumn(socket: Socket, data: MoveColumnMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.targetBoardId, action: 'move-column' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'move-column' }); const { userId } = this.getCurrentUser(socket); try { - await this.boardUc.moveColumn(userId, data.columnMove.columnId, data.targetBoardId, data.columnMove.addedIndex); - - await emitter.emitToClientAndRoom(data); + const column = await this.boardUc.moveColumn( + userId, + data.columnMove.columnId, + data.targetBoardId, + data.columnMove.addedIndex + ); + emitter.emitToClientAndRoom(data, column); } catch (err) { emitter.emitFailure(data); } @@ -259,12 +264,11 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async updateColumnTitle(socket: Socket, data: UpdateColumnTitleMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.columnId, action: 'update-column-title' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-column-title' }); const { userId } = this.getCurrentUser(socket); try { - await this.columnUc.updateColumnTitle(userId, data.columnId, data.newTitle); - - await emitter.emitToClientAndRoom(data); + const column = await this.columnUc.updateColumnTitle(userId, data.columnId, data.newTitle); + emitter.emitToClientAndRoom(data, column); } catch (err) { emitter.emitFailure(data); } @@ -272,14 +276,14 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('update-board-visibility-request') + @TrackExecutionTime() @UseRequestContext() async updateBoardVisibility(socket: Socket, data: UpdateBoardVisibilityMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'update-board-visibility' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-board-visibility' }); const { userId } = this.getCurrentUser(socket); try { - await this.boardUc.updateVisibility(userId, data.boardId, data.isVisible); - - await emitter.emitToClientAndRoom(data); + const board = await this.boardUc.updateVisibility(userId, data.boardId, data.isVisible); + emitter.emitToClientAndRoom(data, board); } catch (err) { emitter.emitFailure(data); } @@ -287,14 +291,14 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('delete-column-request') + @TrackExecutionTime() @UseRequestContext() async deleteColumn(socket: Socket, data: DeleteColumnMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.columnId, action: 'delete-column' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'delete-column' }); const { userId } = this.getCurrentUser(socket); try { - await this.columnUc.deleteColumn(userId, data.columnId); - - await emitter.emitToClientAndRoom(data); + const rootId = await this.columnUc.deleteColumn(userId, data.columnId); + emitter.emitToClientAndRoom(data, rootId); } catch (err) { emitter.emitFailure(data); } @@ -305,13 +309,13 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async fetchCards(socket: Socket, data: FetchCardsMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardIds[0], action: 'fetch-card' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'fetch-card' }); const { userId } = this.getCurrentUser(socket); try { const cards = await this.cardUc.findCards(userId, data.cardIds); const cardResponses = cards.map((card) => CardResponseMapper.mapToResponse(card)); - await emitter.emitToClient({ cards: cardResponses, isOwnAction: false }); + emitter.emitSuccess({ cards: cardResponses }); } catch (err) { emitter.emitFailure(data); } @@ -319,9 +323,10 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('create-element-request') + @TrackExecutionTime() @UseRequestContext() async createElement(socket: Socket, data: CreateContentElementMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'create-element' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'create-element' }); const { userId } = this.getCurrentUser(socket); try { const element = await this.cardUc.createElement(userId, data.cardId, data.type, data.toPosition); @@ -330,7 +335,7 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { ...data, newElement: ContentElementResponseFactory.mapToResponse(element), }; - await emitter.emitToClientAndRoom(responsePayload); + emitter.emitToClientAndRoom(responsePayload, element); } catch (err) { emitter.emitFailure(data); } @@ -341,12 +346,11 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { @TrackExecutionTime() @UseRequestContext() async updateElement(socket: Socket, data: UpdateContentElementMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.elementId, action: 'update-element' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-element' }); const { userId } = this.getCurrentUser(socket); try { - await this.elementUc.updateElement(userId, data.elementId, data.data.content); - - await emitter.emitToClientAndRoom(data); + const element = await this.elementUc.updateElement(userId, data.elementId, data.data.content); + emitter.emitToClientAndRoom(data, element); } catch (err) { emitter.emitFailure(data); } @@ -354,14 +358,15 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('delete-element-request') + @TrackExecutionTime() @UseRequestContext() async deleteElement(socket: Socket, data: DeleteContentElementMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.elementId, action: 'delete-element' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'delete-element' }); const { userId } = this.getCurrentUser(socket); try { - await this.elementUc.deleteElement(userId, data.elementId); - await emitter.emitToClientAndRoom(data); + const rootId = await this.elementUc.deleteElement(userId, data.elementId); + emitter.emitToClientAndRoom(data, rootId); } catch (err) { emitter.emitFailure(data); } @@ -369,30 +374,36 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { } @SubscribeMessage('move-element-request') + @TrackExecutionTime() @UseRequestContext() async moveElement(socket: Socket, data: MoveContentElementMessageParams) { - const emitter = await this.buildBoardSocketEmitter({ socket, id: data.elementId, action: 'move-element' }); + const emitter = this.buildBoardSocketEmitter({ socket, action: 'move-element' }); const { userId } = this.getCurrentUser(socket); try { - await this.cardUc.moveElement(userId, data.elementId, data.toCardId, data.toPosition); - await emitter.emitToClientAndRoom(data); + const element = await this.cardUc.moveElement(userId, data.elementId, data.toCardId, data.toPosition); + emitter.emitToClientAndRoom(data, element); } catch (err) { emitter.emitFailure(data); } await this.updateRoomsAndUsersMetrics(socket); } - private async buildBoardSocketEmitter({ socket, id, action }: { socket: Socket; id: string; action: string }) { - const rootId = await this.getRootIdForId(id); - const room = `board_${rootId}`; + private buildBoardSocketEmitter({ socket, action }: { socket: Socket; action: string }) { + const getRoomName = (boardNode: AnyBoardNode | EntityId) => { + const rootId = typeof boardNode === 'string' ? boardNode : boardNode.rootId; + return `board_${rootId}`; + }; return { - async emitToClient(data: object) { + async joinRoom(boardNode: AnyBoardNode) { + const room = getRoomName(boardNode); await socket.join(room); + }, + emitSuccess(data: object) { socket.emit(`${action}-success`, { ...data, isOwnAction: true }); }, - async emitToClientAndRoom(data: object) { - await socket.join(room); + emitToClientAndRoom(data: object, boardNodeOrRootId: AnyBoardNode | EntityId) { + const room = getRoomName(boardNodeOrRootId); socket.to(room).emit(`${action}-success`, { ...data, isOwnAction: false }); socket.emit(`${action}-success`, { ...data, isOwnAction: true }); }, @@ -401,11 +412,4 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { }, }; } - - private async getRootIdForId(id: string) { - const authorizable = await this.authorizableService.findById(id); - const rootId = authorizable.rootNode.id; - - return rootId; - } } diff --git a/apps/server/src/modules/board/gateway/dto/create-card.message.param.ts b/apps/server/src/modules/board/gateway/dto/create-card.message.param.ts index aecff720b5a..fc596d8bd2c 100644 --- a/apps/server/src/modules/board/gateway/dto/create-card.message.param.ts +++ b/apps/server/src/modules/board/gateway/dto/create-card.message.param.ts @@ -1,6 +1,17 @@ -import { IsMongoId } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsMongoId, IsOptional } from 'class-validator'; +import { ContentElementType } from '../../domain'; export class CreateCardMessageParams { @IsMongoId() columnId!: string; + + @IsEnum(ContentElementType, { each: true }) + @IsOptional() + @ApiPropertyOptional({ + required: false, + isArray: true, + enum: ContentElementType, + }) + requiredEmptyElements?: ContentElementType[]; } diff --git a/apps/server/src/modules/board/gateway/dto/index.ts b/apps/server/src/modules/board/gateway/dto/index.ts index 16c8d1dd64c..35d8c364514 100644 --- a/apps/server/src/modules/board/gateway/dto/index.ts +++ b/apps/server/src/modules/board/gateway/dto/index.ts @@ -1,6 +1,39 @@ -import { MoveCardMessageParams } from './move-card.message.param'; import { CreateCardMessageParams } from './create-card.message.param'; -import { UpdateColumnTitleMessageParams } from './update-column-title.message.param'; +import { CreateColumnMessageParams } from './create-column.message.param'; +import { CreateContentElementMessageParams } from './create-content-element.message.param'; +import { DeleteBoardMessageParams } from './delete-board.message.param'; +import { DeleteCardMessageParams } from './delete-card.message.param'; +import { DeleteContentElementMessageParams } from './delete-content-element.message.param'; import { DeleteColumnMessageParams } from './delete-column.message.param'; +import { FetchBoardMessageParams } from './fetch-board.message.param'; +import { FetchCardsMessageParams } from './fetch-cards.message.param'; +import { MoveCardMessageParams } from './move-card.message.param'; +import { MoveColumnMessageParams } from './move-column.message.param'; +import { MoveContentElementMessageParams } from './move-content-element.message.param'; +import { UpdateBoardTitleMessageParams } from './update-board-title.message.param'; +import { UpdateBoardVisibilityMessageParams } from './update-board-visibility.message.param'; +import { UpdateCardHeightMessageParams } from './update-card-height.message.param'; +import { UpdateCardTitleMessageParams } from './update-card-title.message.param'; +import { UpdateColumnTitleMessageParams } from './update-column-title.message.param'; +import { UpdateContentElementMessageParams } from './update-content-element.message.param'; -export { MoveCardMessageParams, CreateCardMessageParams, UpdateColumnTitleMessageParams, DeleteColumnMessageParams }; +export { + CreateCardMessageParams, + CreateColumnMessageParams, + CreateContentElementMessageParams, + DeleteBoardMessageParams, + DeleteCardMessageParams, + DeleteColumnMessageParams, + DeleteContentElementMessageParams, + FetchBoardMessageParams, + FetchCardsMessageParams, + MoveCardMessageParams, + MoveColumnMessageParams, + MoveContentElementMessageParams, + UpdateBoardTitleMessageParams, + UpdateBoardVisibilityMessageParams, + UpdateCardHeightMessageParams, + UpdateCardTitleMessageParams, + UpdateColumnTitleMessageParams, + UpdateContentElementMessageParams, +}; diff --git a/apps/server/src/modules/board/index.ts b/apps/server/src/modules/board/index.ts index 1148ed77b61..0321fee95c2 100644 --- a/apps/server/src/modules/board/index.ts +++ b/apps/server/src/modules/board/index.ts @@ -1,28 +1,37 @@ -export { BoardModule } from './board.module'; export { BoardConfig } from './board.config'; +export { BoardModule } from './board.module'; +export { AnyElementContentBody, LinkContentBody, RichTextContentBody } from './controller/dto'; export { - BoardNode, - BoardNodeAuthorizable, - BoardExternalReferenceType, + AnyBoardNode, BoardExternalReference, + BoardExternalReferenceType, BoardLayout, + BoardNode, + BoardNodeAuthorizable, BoardNodeFactory, - ColumnBoard, // @modules/authorization/domain/rules/board-node.rule.ts BoardRoles, + Card, + Column, + ColumnBoard, + ContentElementType, + // @modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts + MediaBoard, + SubmissionItem, + UserWithBoardRoles, + isCard, + isColumn, isDrawingElement, + isLinkElement, + isRichTextElement, isSubmissionItem, isSubmissionItemContent, - SubmissionItem, - UserWithBoardRoles, - // @modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts - MediaBoard, } from './domain'; export { + BoardCommonToolService, BoardNodeAuthorizableService, BoardNodeService, - BoardCommonToolService, ColumnBoardService, MediaAvailableLineService, MediaBoardService, diff --git a/apps/server/src/modules/board/loadtest/readme.md b/apps/server/src/modules/board/loadtest/readme.md new file mode 100644 index 00000000000..24cbd18605c --- /dev/null +++ b/apps/server/src/modules/board/loadtest/readme.md @@ -0,0 +1,76 @@ +# Loadtesting the boards + +The socket.io documentation suggests to use the tool artillery in order to load test a socket-io tool like our board-collaboration service. + +For defining scenarios you need to use/create Yaml-files that define which operations with which parameters need to be executed in which order. + +Some sceneraios were already prepared and are stored in the subfolder scenarios. + +## install artillery + +To run artillery from your local environment you need to install it first including an adapter that supports socketio-v3-websocket communication: + +```sh +npm install -g artillery artillery-engine-socketio-v3 +``` + +## manual execution + +To execute a scenario you can run artillery from the shell / commandline...: + +Using the `--variables` parameter it is possible to define several variables and there values that can be used in the scenerio-yaml-file: + +- **target**: defines the base url for all requests (REST and WebSocket) + e.g. `https://main.dbc.dbildungscloud.dev` +- **token**: a valid JWT for the targeted system +- **board_id**: id of an existing board the tests should be executed on + +```bash +npx artillery run --variables "{'target': 'https://main.dbc.dbildungscloud.dev', 'token': 'eJ....', 'board_id': '668d0e03bf3689d12e1e86fb' }" './scenarios/3users.yml' --output artilleryreport.json +``` + +On Windows Powershell, the variables value needs to be wrapped in singlequotes, and inside the json you need to use backslash-escaped doublequotes: + +```powershell +npx artillery run --variables '{\"target\": \"https://main.dbc.dbildungscloud.dev\", \"token\": \"eJ....\", \"board_id\": \"668d0e03bf3689d12e1e86fb\" }' './scenarios/3users.yml' --output artilleryreport.json +``` + +## visualizing the recorded results + +It is possible to generate a HTML-report based on the recorded data. + +```powershell +npx artillery report --output=$board_title.html artilleryreport.json +``` + +## automatic execution + +You can run one of the existing scenarios by executing: + +```bash +bash runScenario.sh +``` + +This will: + +1. let you choose from scenario-files +2. create a fresh JWT-webtoken +3. create a fresh board (in one of the courses) the user has access to +4. name the board by a combination of datetime and the scenario name. +5. output a link to the generated board (in order open and see the test live) +6. start the execution of the scenario against this newly created board +7. generate a html report in the end + +You can also provide the target as the first and the name of the scenario as the second parameter - to avoid the need to select those. Here is an example: + +```bash +bash runScenario.sh https://bc-6854-basic-load-tests.nbc.dbildungscloud.dev 3users +``` + +## password + +By typeing `export CARL_CORD_PASSWORD=realpassword` the script will not ask you anymore for the password to create a token. + +## Todos + +- [ ] enable optional parameter course_id diff --git a/apps/server/src/modules/board/loadtest/runScenario.sh b/apps/server/src/modules/board/loadtest/runScenario.sh new file mode 100644 index 00000000000..0187f1c579f --- /dev/null +++ b/apps/server/src/modules/board/loadtest/runScenario.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +function select_target() { + declare -a targets=("https://main.nbc.dbildungscloud.dev" "https://bc-6854-basic-load-tests.nbc.dbildungscloud.dev") + echo "Please select the target for the test:" >&2 + select target in "${targets[@]}"; do + if [[ -n $target ]]; then + break + else + echo "Invalid selection. Please try again." >&2 + fi + done +} + +function select_scenario() { + # list files in the scenarios directory + scenarios_dir="./scenarios" + declare -a scenario_files=($(ls $scenarios_dir)) + + echo "Please select a scenario file for the test:" >&2 + select scenario_file in "${scenario_files[@]}"; do + if [[ -n $scenario_file ]]; then + echo "You have selected: $scenario_file" >&2 + break + else + echo "Invalid selection. Please try again." >&2 + fi + done + + scenario_name="${scenario_file%.*}" +} + +function get_credentials() { + if [ -z "$CARL_CORD_PASSWORD" ]; then + echo "Password for Carl Cord is unknown. Provide it as an enviroment variable (CARL_CORD_PASSWORD) or enter it:" + read CARL_CORD_PASSWORD + export CARL_CORD_PASSWORD + fi +} + +function get_token() { + response=$(curl -s -f -X 'POST' \ + "$target/api/v3/authentication/local" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d "{ + \"username\": \"lehrer@schul-cloud.org\", + \"password\": \"$CARL_CORD_PASSWORD\" + }") + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to get token. Please check your credentials and target URL." >&2 + exit 1 + fi + + token=$(echo $response | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p') +} + +function get_course_id() { + response=$(curl -s -f -X 'GET' \ + "$target/api/v3/courses" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer $token") + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to get course list. Please check your credentials and target URL." >&2 + exit 1 + fi + + course_id=$(echo $response | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') +} + +function create_board_title() { + current_date=$(date +%Y-%m-%d_%H:%M) + board_title="${current_date}_$1" +} + +function create_board() { + response=$(curl -s -f -X 'POST' \ + "$target/api/v3/boards" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $token" \ + -d "{ + \"title\": \"$board_title\", + \"parentId\": \"$course_id\", + \"parentType\": \"course\", + \"layout\": \"columns\" + }") + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to create a board." >&2 + exit 1 + fi + + board_id=$(echo $response | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' ) +} + +if [ -z "$1" ]; then + select_target +else + target=$1 +fi +echo " " +echo "target: $target" + + +if [ -z "$2" ]; then + select_scenario + echo "scenario_name: $scenario_name" +else + scenario_name="$2" + scenario_name=${scenario_name//.yml/} +fi +echo "scenario_name: $scenario_name" + +get_credentials + +get_token +echo "token: ${token:0:50}..." +echo " " + +get_course_id +echo "course_id: $course_id" +echo " " + +create_board_title $scenario_name +echo "board_title: $board_title" + +create_board +echo "board_id $board_id" + +echo "board: $target/rooms/$board_id/board" +echo " " +echo "Running artillery test..." + +npx artillery run --variables "{\"target\": \"$target\", \"token\": \"$token\", \"board_id\": \"$board_id\" }" "./scenarios/$scenario_name.yml" --output artilleryreport.json + +npx artillery report --output=$board_title.html artilleryreport.json diff --git a/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml b/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml new file mode 100644 index 00000000000..567cbaf703a --- /dev/null +++ b/apps/server/src/modules/board/loadtest/scenarios/30users_5minutes.yml @@ -0,0 +1,75 @@ +config: + target: '{{ target }}' + engines: + socketio: + transport: ['websocket', 'polling'] + path: '/board-collaboration' + socketio-v3: + path: '/board-collaboration' + timeout: 1000000 + extraHeaders: + Cookie: 'jwt={{ token }}' + + phases: + - duration: 300 + arrivalRate: 10 + maxVusers: 30 + +scenarios: + - name: create card + engine: socketio-v3 + socketio-v3: + extraHeaders: + Cookie: 'jwt={{ token }}' + flow: + - think: 1 + + - emit: + channel: 'fetch-board-request' + data: + boardId: '{{ board_id }}' + + - think: 1 + + - emit: + channel: 'create-column-request' + data: + boardId: '{{ board_id }}' + response: + on: 'create-column-success' + capture: + - json: $.newColumn.id + as: columnId + + - think: 1 + + - loop: + - emit: + channel: 'create-card-request' + data: + columnId: '{{ columnId}}' + response: + on: 'create-card-success' + capture: + - json: $.newCard.id + as: cardId + + - think: 1 + + - emit: + channel: 'fetch-card-request' + data: + cardIds: + - '{{ cardId }}' + + - think: 2 + + - emit: + channel: 'update-card-title-request' + data: + cardId: '{{ cardId }}' + newTitle: 'Card {{ cardId}}' + + - think: 1 + + count: 20 diff --git a/apps/server/src/modules/board/loadtest/scenarios/3users.yml b/apps/server/src/modules/board/loadtest/scenarios/3users.yml new file mode 100644 index 00000000000..4fbeef037c8 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/scenarios/3users.yml @@ -0,0 +1,57 @@ +config: + target: '{{ target }}' + engines: + socketio: + transport: ['websocket', 'polling'] + path: '/board-collaboration' + socketio-v3: + path: '/board-collaboration' + timeout: 1000000 + extraHeaders: + Cookie: 'jwt={{ token }}' + + phases: + - duration: 1 + arrivalRate: 3 + +scenarios: + - name: create card + engine: socketio-v3 + socketio-v3: + extraHeaders: + Cookie: 'jwt={{ token }}' + flow: + - log: '{{ target }}' + - think: 1 + + - emit: + channel: 'create-column-request' + data: + boardId: '{{ board_id }}' + response: + on: 'create-column-success' + capture: + - json: $.newColumn.id + as: columnId + + - think: 1 + + - emit: + channel: 'create-card-request' + data: + columnId: '{{ columnId}}' + response: + on: 'create-card-success' + capture: + - json: $.newCard.id + as: cardId + + - think: 1 + + - emit: + channel: 'update-card-title-request' + data: + cardId: '{{ cardId }}' + newTitle: 'One {{ cardId}}' + + - think: 2 diff --git a/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml b/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml new file mode 100644 index 00000000000..ad7e993f829 --- /dev/null +++ b/apps/server/src/modules/board/loadtest/scenarios/6createCard_6UpdateTitle.yml @@ -0,0 +1,70 @@ +config: + target: '{{ target }}' + engines: + socketio: + transport: ['websocket', 'polling'] + path: '/board-collaboration' + socketio-v3: + path: '/board-collaboration' + timeout: 1000000 + extraHeaders: + Cookie: 'jwt={{ token }}' + + phases: + - duration: 2 + arrivalRate: 50 + +scenarios: + - name: create card + engine: socketio-v3 + socketio: + extraHeaders: + Cookie: 'jwt={{ token }}' + socketio-v3: + extraHeaders: + Cookie: 'jwt={{ token }}' + flow: + - think: 1 + + - emit: + channel: 'create-column-request' + data: + boardId: '{{ board_id }}' + response: + on: 'create-column-success' + capture: + - json: $.newColumn.id + as: columnId + + - think: 2 + + - loop: + - emit: + channel: 'create-card-request' + data: + columnId: '{{ columnId}}' + response: + on: 'create-card-success' + capture: + - json: $.newCard.id + as: cardId + + - think: 1 + + - emit: + channel: 'fetch-card-request' + data: + cardIds: + - '{{ cardId }}' + + - think: 2 + + - emit: + channel: 'update-card-title-request' + data: + cardId: '{{ cardId }}' + newTitle: 'Card {{ cardId}}' + + - think: 1 + + count: 6 diff --git a/apps/server/src/modules/board/metrics/metrics.service.ts b/apps/server/src/modules/board/metrics/metrics.service.ts index 8019220b40e..b2a54d5ce75 100644 --- a/apps/server/src/modules/board/metrics/metrics.service.ts +++ b/apps/server/src/modules/board/metrics/metrics.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { UserService } from '@src/modules/user'; -import { Gauge, Summary, register } from 'prom-client'; +import { Gauge, Summary, register, Counter } from 'prom-client'; type ClientId = string; type Role = 'owner' | 'editor' | 'viewer'; @@ -19,6 +19,10 @@ export class MetricsService { private executionTimesSummary: Map> = new Map(); + private actionCounters: Map> = new Map(); + + private actionGauges: Map> = new Map(); + constructor(private readonly userService: UserService) { this.numberOfBoardroomsOnServerCounter = new Gauge({ name: 'sc_boards_rooms', @@ -88,13 +92,55 @@ export class MetricsService { summary = new Summary({ name: `sc_boards_execution_time_${actionName}`, help: 'Average execution time of a specific action in milliseconds', - maxAgeSeconds: 60, + maxAgeSeconds: 600, ageBuckets: 5, - percentiles: [0.01, 0.1, 0.9, 0.99], + percentiles: [0.01, 0.1, 0.5, 0.9, 0.99], + pruneAgedBuckets: true, }); this.executionTimesSummary.set(actionName, summary); register.registerMetric(summary); } + console.log(actionName, `executionTime: ${value.toFixed(3)} ms`); summary.observe(value); } + + public incrementActionCount(actionName: string): void { + let counter = this.actionCounters.get(actionName); + + if (!counter) { + counter = new Counter({ + name: `sc_boards_count_${actionName}`, + help: 'Number of calls for a specific action per minute', + // async collect() { + // // Invoked when the registry collects its metrics' values. + // const currentValue = await somethingAsync(); + // this.set(currentValue); + // }, + }); + this.actionCounters.set(actionName, counter); + register.registerMetric(counter); + } + counter.inc(); + console.log(actionName, `count increased`); + } + + public incrementActionGauge(actionName: string): void { + let counter = this.actionGauges.get(actionName); + + if (!counter) { + counter = new Gauge({ + name: `sc_boards_count2_${actionName}`, + help: 'Number of calls for a specific action per minute', + // async collect() { + // // Invoked when the registry collects its metrics' values. + // const currentValue = await somethingAsync(); + // this.set(currentValue); + // }, + }); + this.actionGauges.set(actionName, counter); + register.registerMetric(counter); + } + counter.inc(); + console.log(actionName, `count increased`); + } } diff --git a/apps/server/src/modules/board/service/board-node-authorizable.service.ts b/apps/server/src/modules/board/service/board-node-authorizable.service.ts index bc9a846311f..b813761163b 100644 --- a/apps/server/src/modules/board/service/board-node-authorizable.service.ts +++ b/apps/server/src/modules/board/service/board-node-authorizable.service.ts @@ -1,7 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { type EntityId } from '@shared/domain/types'; import { type AuthorizationLoaderService } from '@modules/authorization'; -import { AnyBoardNode, BoardNodeAuthorizable } from '../domain'; +import { AnyBoardNode, BoardNodeAuthorizable, UserWithBoardRoles } from '../domain'; import { BoardNodeRepo } from '../repo'; import { BoardContextService } from './internal/board-context.service'; import { BoardNodeService } from './board-node.service'; @@ -40,4 +40,51 @@ export class BoardNodeAuthorizableService implements AuthorizationLoaderService return boardNodeAuthorizable; } + + async getBoardAuthorizables(boardNodes: AnyBoardNode[]): Promise { + const rootIds = boardNodes.map((node) => node.rootId); + const parentIds = boardNodes.map((node) => node.parentId).filter((defined) => defined) as EntityId[]; + const boardNodeMap = await this.getBoardNodeMap([...rootIds, ...parentIds]); + const promises = boardNodes.map((boardNode) => { + const rootNode = boardNodeMap[boardNode.rootId]; + return this.boardContextService.getUsersWithBoardRoles(rootNode).then((users) => { + return { id: boardNode.id, users }; + }); + }); + + const results = await Promise.all(promises); + const usersMap = results.reduce((acc, { id, users }) => { + acc[id] = users; + return acc; + }, {} as Record); + + const boardNodeAuthorizables = boardNodes.map((boardNode) => { + const rootNode = boardNodeMap[boardNode.rootId]; + const parentNode = boardNode.parentId ? boardNodeMap[boardNode.parentId] : undefined; + const users = usersMap[boardNode.id]; + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users, + id: boardNode.id, + boardNode, + rootNode, + parentNode, + }); + return boardNodeAuthorizable; + }); + + return boardNodeAuthorizables; + } + + private async getBoardNodeMap(ids: EntityId[]): Promise> { + const idsUnique = Array.from(new Set(ids)); + const boardNodes = await this.boardNodeService.findByIds(idsUnique, 1); + const nodesMap: Record = boardNodes.reduce( + (map: Record, boardNode) => { + map[boardNode.id] = boardNode; + return map; + }, + {} as Record + ); + return nodesMap; + } } diff --git a/apps/server/src/modules/board/service/board-node.service.ts b/apps/server/src/modules/board/service/board-node.service.ts index bd8092a2e78..0ac96b627b1 100644 --- a/apps/server/src/modules/board/service/board-node.service.ts +++ b/apps/server/src/modules/board/service/board-node.service.ts @@ -76,6 +76,12 @@ export class BoardNodeService { return boardNode; } + async findByIds(ids: EntityId[], depth?: number): Promise { + const boardNode = this.boardNodeRepo.findByIds(ids, depth); + + return boardNode; + } + async findByClassAndId( Constructor: { new (props: S): T }, id: EntityId, diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 7402a8c1436..6c9201e4f1d 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -3,7 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { StorageLocation } from '@modules/files-storage/entity'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; +import { ToolConfig } from '@modules/tool/tool-config'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; @@ -35,8 +36,8 @@ describe(BoardNodeCopyService.name, () => { providers: [ BoardNodeCopyService, { - provide: ToolFeatures, - useValue: createMock(), + provide: ConfigService, + useValue: createMock>(), }, { provide: ContextExternalToolService, diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 99299317481..d4c57e4d0e6 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -3,7 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { StorageLocation } from '@modules/files-storage/entity'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; +import { ToolConfig } from '@modules/tool/tool-config'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; @@ -43,13 +44,13 @@ import { BoardNodeCopyService } from './board-node-copy.service'; describe(BoardNodeCopyService.name, () => { let module: TestingModule; let service: BoardNodeCopyService; - const toolFeatures: IToolFeatures = { - ctlToolsTabEnabled: false, - ltiToolsTabEnabled: false, - maxExternalToolLogoSizeInBytes: 0, - backEndUrl: '', - ctlToolsCopyEnabled: false, - ctlToolsReloadTimeMs: 0, + const config: ToolConfig = { + FEATURE_CTL_TOOLS_TAB_ENABLED: false, + FEATURE_LTI_TOOLS_TAB_ENABLED: false, + CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES: 0, + CTL_TOOLS_BACKEND_URL: '', + FEATURE_CTL_TOOLS_COPY_ENABLED: false, + CTL_TOOLS_RELOAD_TIME_MS: 0, }; let contextExternalToolService: DeepMocked; let copyHelperService: DeepMocked; @@ -59,8 +60,10 @@ describe(BoardNodeCopyService.name, () => { providers: [ BoardNodeCopyService, { - provide: ToolFeatures, - useValue: toolFeatures, + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof ToolConfig) => config[key]), + }, }, { provide: ContextExternalToolService, @@ -81,7 +84,7 @@ describe(BoardNodeCopyService.name, () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -404,7 +407,7 @@ describe(BoardNodeCopyService.name, () => { const setupCopyEnabled = () => { const { copyContext, externalToolElement } = setup(); - toolFeatures.ctlToolsCopyEnabled = true; + config.FEATURE_CTL_TOOLS_COPY_ENABLED = true; return { copyContext, externalToolElement }; }; @@ -474,7 +477,7 @@ describe(BoardNodeCopyService.name, () => { const setupCopyDisabled = () => { const { copyContext, externalToolElement } = setup(); - toolFeatures.ctlToolsCopyEnabled = false; + config.FEATURE_CTL_TOOLS_COPY_ENABLED = false; return { copyContext, externalToolElement }; }; diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index dd254390eb9..4686f6cfcae 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -3,8 +3,9 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from ' import { CopyFileDto } from '@modules/files-storage-client/dto'; import { ContextExternalToolService } from '@modules/tool/context-external-tool'; import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; -import { Inject, Injectable } from '@nestjs/common'; +import { ToolConfig } from '@modules/tool/tool-config'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { EntityId } from '@shared/domain/types'; import { AnyBoardNode, @@ -34,7 +35,7 @@ export interface CopyContext { @Injectable() export class BoardNodeCopyService { constructor( - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly configService: ConfigService, private readonly contextExternalToolService: ContextExternalToolService, private readonly copyHelperService: CopyHelperService ) {} @@ -286,7 +287,7 @@ export class BoardNodeCopyService { }); let status: CopyStatusEnum; - if (this.toolFeatures.ctlToolsCopyEnabled && original.contextExternalToolId) { + if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED') && original.contextExternalToolId) { const linkedTool = await this.contextExternalToolService.findById(original.contextExternalToolId); if (linkedTool) { diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 4490d54f206..a0425ffa600 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -65,22 +65,24 @@ export class BoardUc { return board.context; } - async deleteBoard(userId: EntityId, boardId: EntityId): Promise { + async deleteBoard(userId: EntityId, boardId: EntityId): Promise { this.logger.debug({ action: 'deleteBoard', userId, boardId }); const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); await this.boardPermissionService.checkPermission(userId, board, Action.write); await this.boardNodeService.delete(board); + return board; } - async updateBoardTitle(userId: EntityId, boardId: EntityId, title: string): Promise { + async updateBoardTitle(userId: EntityId, boardId: EntityId, title: string): Promise { this.logger.debug({ action: 'updateBoardTitle', userId, boardId, title }); const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); await this.boardPermissionService.checkPermission(userId, board, Action.write); await this.boardNodeService.updateTitle(board, title); + return board; } async createColumn(userId: EntityId, boardId: EntityId): Promise { @@ -101,7 +103,7 @@ export class BoardUc { columnId: EntityId, targetBoardId: EntityId, targetPosition: number - ): Promise { + ): Promise { this.logger.debug({ action: 'moveColumn', userId, columnId, targetBoardId, targetPosition }); const column = await this.boardNodeService.findByClassAndId(Column, columnId); @@ -111,6 +113,7 @@ export class BoardUc { await this.boardPermissionService.checkPermission(userId, targetBoard, Action.write); await this.boardNodeService.move(column, targetBoard, targetPosition); + return column; } async copyBoard(userId: EntityId, boardId: EntityId): Promise { @@ -137,10 +140,11 @@ export class BoardUc { return copyStatus; } - async updateVisibility(userId: EntityId, boardId: EntityId, isVisible: boolean): Promise { + async updateVisibility(userId: EntityId, boardId: EntityId, isVisible: boolean): Promise { const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); await this.boardPermissionService.checkPermission(userId, board, Action.write); await this.boardNodeService.updateVisibility(board, isVisible); + return board; } } 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 5d232081d92..be5bba4afa8 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -1,5 +1,4 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; import { Action, AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities, userFactory } from '@shared/testing'; @@ -73,14 +72,26 @@ describe(CardUc.name, () => { const cards = cardFactory.buildList(3); const cardIds = cards.map((c) => c.id); - boardNodeAuthorizableService.getBoardAuthorizable.mockResolvedValue( + boardNodeAuthorizableService.getBoardAuthorizables.mockResolvedValue([ new BoardNodeAuthorizable({ users: [], - id: new ObjectId().toHexString(), + id: cards[0].id, boardNode: cards[0], rootNode: columnBoardFactory.build(), - }) - ); + }), + new BoardNodeAuthorizable({ + users: [], + id: cards[1].id, + boardNode: cards[1], + rootNode: columnBoardFactory.build(), + }), + new BoardNodeAuthorizable({ + users: [], + id: cards[2].id, + boardNode: cards[2], + rootNode: columnBoardFactory.build(), + }), + ]); authorizationService.hasPermission.mockReturnValue(true); return { user, cards, cardIds }; @@ -109,7 +120,7 @@ describe(CardUc.name, () => { await uc.findCards(user.id, cardIds); - expect(boardNodeAuthorizableService.getBoardAuthorizable).toHaveBeenCalledTimes(3); + expect(boardNodeAuthorizableService.getBoardAuthorizables).toHaveBeenCalledTimes(1); }); it('should call the service to check the user permission', async () => { @@ -143,6 +154,7 @@ describe(CardUc.name, () => { describe('when deleting a card', () => { it('should call the service to find the card', async () => { const { user, card } = setup(); + boardNodeService.findByClassAndId.mockResolvedValueOnce(card); await uc.deleteCard(user.id, card.id); diff --git a/apps/server/src/modules/board/uc/card.uc.ts b/apps/server/src/modules/board/uc/card.uc.ts index eac9f9c00a3..6ab2f392766 100644 --- a/apps/server/src/modules/board/uc/card.uc.ts +++ b/apps/server/src/modules/board/uc/card.uc.ts @@ -3,7 +3,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; -import { AnyContentElement, BoardNodeFactory, Card, ContentElementType } from '../domain'; +import { AnyBoardNode, AnyContentElement, BoardNodeFactory, Card, ContentElementType } from '../domain'; import { BoardNodeAuthorizableService, BoardNodePermissionService, BoardNodeService } from '../service'; @Injectable() @@ -29,48 +29,47 @@ export class CardUc { const user = await this.authorizationService.getUserWithPermissions(userId); const context: AuthorizationContext = { action: Action.read, requiredPermissions: [] }; - const promises = cards.map((card) => - this.boardNodeAuthorizableService.getBoardAuthorizable(card).then((boardNodeAuthorizable) => { - return { boardNodeAuthorizable, boardNode: card }; - }) - ); - const result = await Promise.all(promises); - - const allowedCards = result.reduce((allowedNodes: Card[], { boardNodeAuthorizable, boardNode }) => { + const boardAuthorizables = await this.boardNodeAuthorizableService.getBoardAuthorizables(cards); + + const allowedCards = boardAuthorizables.reduce((allowedNodes: AnyBoardNode[], boardNodeAuthorizable) => { if (this.authorizationService.hasPermission(user, boardNodeAuthorizable, context)) { - allowedNodes.push(boardNode); + allowedNodes.push(boardNodeAuthorizable.boardNode); } return allowedNodes; - }, []); + }, []) as Card[]; return allowedCards; } - async updateCardHeight(userId: EntityId, cardId: EntityId, height: number): Promise { + async updateCardHeight(userId: EntityId, cardId: EntityId, height: number): Promise { this.logger.debug({ action: 'updateCardHeight', userId, cardId, height }); const card = await this.boardNodeService.findByClassAndId(Card, cardId); await this.boardNodePermissionService.checkPermission(userId, card, Action.write); await this.boardNodeService.updateHeight(card, height); + return card; } - async updateCardTitle(userId: EntityId, cardId: EntityId, title: string): Promise { + async updateCardTitle(userId: EntityId, cardId: EntityId, title: string): Promise { this.logger.debug({ action: 'updateCardTitle', userId, cardId, title }); const card = await this.boardNodeService.findByClassAndId(Card, cardId); await this.boardNodePermissionService.checkPermission(userId, card, Action.write); await this.boardNodeService.updateTitle(card, title); + return card; } - async deleteCard(userId: EntityId, cardId: EntityId): Promise { + async deleteCard(userId: EntityId, cardId: EntityId): Promise { this.logger.debug({ action: 'deleteCard', userId, cardId }); const card = await this.boardNodeService.findByClassAndId(Card, cardId); + const { rootId } = card; await this.boardNodePermissionService.checkPermission(userId, card, Action.write); await this.boardNodeService.delete(card); + return rootId; } // --- elements --- @@ -98,7 +97,7 @@ export class CardUc { elementId: EntityId, targetCardId: EntityId, targetPosition: number - ): Promise { + ): Promise { this.logger.debug({ action: 'moveElement', userId, elementId, targetCardId, targetPosition }); const element = await this.boardNodeService.findContentElementById(elementId); @@ -108,5 +107,7 @@ export class CardUc { await this.boardNodePermissionService.checkPermission(userId, targetCard, Action.write); await this.boardNodeService.move(element, targetCard, targetPosition); + + return element; } } diff --git a/apps/server/src/modules/board/uc/column.uc.spec.ts b/apps/server/src/modules/board/uc/column.uc.spec.ts index 1c39a2fa57e..3c80734550e 100644 --- a/apps/server/src/modules/board/uc/column.uc.spec.ts +++ b/apps/server/src/modules/board/uc/column.uc.spec.ts @@ -73,6 +73,7 @@ describe(ColumnUc.name, () => { describe('when deleting a column', () => { it('should call the service to find the column', async () => { const { user, column } = setup(); + boardNodeService.findByClassAndId.mockResolvedValueOnce(column); await uc.deleteColumn(user.id, column.id); diff --git a/apps/server/src/modules/board/uc/column.uc.ts b/apps/server/src/modules/board/uc/column.uc.ts index 8e8a0c1a06e..3f95c69cdbd 100644 --- a/apps/server/src/modules/board/uc/column.uc.ts +++ b/apps/server/src/modules/board/uc/column.uc.ts @@ -17,22 +17,26 @@ export class ColumnUc { this.logger.setContext(ColumnUc.name); } - async deleteColumn(userId: EntityId, columnId: EntityId): Promise { + async deleteColumn(userId: EntityId, columnId: EntityId): Promise { this.logger.debug({ action: 'deleteColumn', userId, columnId }); const column = await this.boardNodeService.findByClassAndId(Column, columnId); + const { rootId } = column; await this.boardNodePermissionService.checkPermission(userId, column, Action.write); await this.boardNodeService.delete(column); + + return rootId; } - async updateColumnTitle(userId: EntityId, columnId: EntityId, title: string): Promise { + async updateColumnTitle(userId: EntityId, columnId: EntityId, title: string): Promise { this.logger.debug({ action: 'updateColumnTitle', userId, columnId, title }); const column = await this.boardNodeService.findByClassAndId(Column, columnId); await this.boardNodePermissionService.checkPermission(userId, column, Action.write); await this.boardNodeService.updateTitle(column, title); + return column; } async createCard( @@ -53,7 +57,7 @@ export class ColumnUc { return card; } - async moveCard(userId: EntityId, cardId: EntityId, targetColumnId: EntityId, targetPosition: number): Promise { + async moveCard(userId: EntityId, cardId: EntityId, targetColumnId: EntityId, targetPosition: number): Promise { this.logger.debug({ action: 'moveCard', userId, cardId, targetColumnId, toPosition: targetPosition }); const card = await this.boardNodeService.findByClassAndId(Card, cardId); @@ -63,5 +67,6 @@ export class ColumnUc { await this.boardNodePermissionService.checkPermission(userId, targetColumn, Action.write); await this.boardNodeService.move(card, targetColumn, targetPosition); + return card; } } 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 2315b3788d0..471be5363a4 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -135,6 +135,7 @@ describe(ElementUc.name, () => { it('should call the service to find the element', async () => { const { user, element } = setup(); + boardNodeService.findContentElementById.mockResolvedValueOnce(element); await uc.deleteElement(user.id, element.id); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index cff947cd92a..0d07c9c468c 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -37,11 +37,13 @@ export class ElementUc { return element; } - async deleteElement(userId: EntityId, elementId: EntityId): Promise { + async deleteElement(userId: EntityId, elementId: EntityId): Promise { const element = await this.boardNodeService.findContentElementById(elementId); + const { rootId } = element; await this.boardPermissionService.checkPermission(userId, element, Action.write); await this.boardNodeService.delete(element); + return rootId; } async checkElementReadPermission(userId: EntityId, elementId: EntityId): Promise { diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts index 5c5db130cf5..308f05b2ff8 100644 --- a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts @@ -1,109 +1,95 @@ import { faker } from '@faker-js/faker'; -import AdmZip from 'adm-zip'; import { - CommonCartridgeElementType, - CommonCartridgeIntendedUseType, - CommonCartridgeResourceType, - CommonCartridgeVersion, -} from '../common-cartridge.enums'; -import { CommonCartridgeElementProps } from '../elements/common-cartridge-element-factory'; -import { CommonCartridgeResourceProps } from '../resources/common-cartridge-resource-factory'; -import { CommonCartridgeFileBuilder } from './common-cartridge-file-builder'; -import { CommonCartridgeOrganizationBuilderOptions } from './common-cartridge-organization-builder'; + createCommonCartridgeMetadataElementProps, + createCommonCartridgeOrganizationProps, +} from '../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWebLinkResourceProps } from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../elements/common-cartridge-element-factory'; +import { MissingMetadataLoggableException } from '../errors'; +import { CommonCartridgeFileBuilder, CommonCartridgeFileBuilderProps } from './common-cartridge-file-builder'; +import { CommonCartridgeOrganizationNode } from './common-cartridge-organization-node'; describe('CommonCartridgeFileBuilder', () => { - const getFileContentAsString = (zip: AdmZip, path: string): string | undefined => - zip.getEntry(path)?.getData().toString(); + let sut: CommonCartridgeFileBuilder; - describe('build', () => { - describe('when a common cartridge archive has been created', () => { - const setup = async () => { - const metadataProps: CommonCartridgeElementProps = { - type: CommonCartridgeElementType.METADATA, - title: faker.lorem.words(), - creationDate: new Date(), - copyrightOwners: ['John Doe', 'Jane Doe'], - }; - const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { - identifier: faker.string.uuid(), - title: faker.lorem.words(), - }; - const resourceProps: CommonCartridgeResourceProps = { - type: CommonCartridgeResourceType.WEB_CONTENT, - identifier: faker.string.uuid(), - title: faker.lorem.words(), - html: faker.lorem.paragraphs(), - intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, - }; - const builder = new CommonCartridgeFileBuilder({ - version: CommonCartridgeVersion.V_1_1_0, - identifier: faker.string.uuid(), - }); - - builder - .addMetadata(metadataProps) - .addOrganization(organizationOptions) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps); - - const archive = new AdmZip(await builder.build()); - - return { archive, metadataProps, organizationOptions, resourceProps }; - }; + const builderProps: CommonCartridgeFileBuilderProps = { + version: CommonCartridgeVersion.V_1_1_0, + identifier: faker.string.uuid(), + }; - it('should have a imsmanifest.xml in archive root', async () => { - const { archive } = await setup(); + beforeEach(() => { + sut = new CommonCartridgeFileBuilder(builderProps); + jest.clearAllMocks(); + }); - const manifest = getFileContentAsString(archive, 'imsmanifest.xml'); + describe('addMetadata', () => { + describe('when metadata is added to the CommonCartridgeFileBuilder', () => { + const setup = () => { + const createElementSpy = jest.spyOn(CommonCartridgeElementFactory, 'createElement'); + const metadataProps = createCommonCartridgeMetadataElementProps(); - expect(manifest).toBeDefined(); - }); + return { metadataProps, createElementSpy }; + }; - it('should have included the resource in organization folder', async () => { - const { archive, organizationOptions, resourceProps } = await setup(); + it('should set the metadata element', () => { + const { metadataProps, createElementSpy } = setup(); - const resource = getFileContentAsString( - archive, - `${organizationOptions.identifier}/${resourceProps.identifier}.html` - ); + sut.addMetadata(metadataProps); - expect(resource).toBeDefined(); + expect(createElementSpy).toHaveBeenCalledWith({ ...metadataProps, version: builderProps.version }); }); + }); + }); - it('should have included the resource in sub-organization folder', async () => { - const { archive, organizationOptions, resourceProps } = await setup(); - - const resource = getFileContentAsString( - archive, - `${organizationOptions.identifier}/${organizationOptions.identifier}/${resourceProps.identifier}.html` - ); + describe('createOrganization', () => { + describe('when an organization is created in the CommonCartridgeFileBuilder', () => { + const setup = () => { + const organizationProps = createCommonCartridgeOrganizationProps(); - expect(resource).toBeDefined(); - }); + return { organizationProps }; + }; - it('should have included the resource in sub-sub-organization folder', async () => { - const { archive, organizationOptions, resourceProps } = await setup(); + it('should create and return an organization node', () => { + const { organizationProps } = setup(); - const resource = getFileContentAsString( - archive, - `${organizationOptions.identifier}/${organizationOptions.identifier}/${organizationOptions.identifier}/${resourceProps.identifier}.html` - ); + const organizationNode = sut.createOrganization(organizationProps); - expect(resource).toBeDefined(); + expect(organizationNode).toBeInstanceOf(CommonCartridgeOrganizationNode); }); }); + }); - describe('when metadata has not been provide', () => { - const sut = new CommonCartridgeFileBuilder({ - version: CommonCartridgeVersion.V_1_1_0, - identifier: faker.string.uuid(), + describe('build', () => { + describe('when metadata has not been provided', () => { + it('should throw MissingMetadataLoggableException', () => { + expect(() => { + sut.build(); + }).toThrow(MissingMetadataLoggableException); }); + }); + + describe('when metadata has been provided', () => { + const setup = () => { + const metadataProps = createCommonCartridgeMetadataElementProps(); + const organizationProps = createCommonCartridgeOrganizationProps(); + const resourceProps = createCommonCartridgeWebLinkResourceProps(); + + return { metadataProps, organizationProps, resourceProps }; + }; + + it('should build the common cartridge file', () => { + const { metadataProps, organizationProps, resourceProps } = setup(); + + sut.addMetadata(metadataProps); + + const org = sut.createOrganization(organizationProps); + + org.addResource(resourceProps); + + const result = sut.build(); - it('should throw an error', async () => { - await expect(sut.build()).rejects.toThrow('Metadata is not defined'); + expect(result).toBeDefined(); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts index 0f605b5561d..35e84aa7115 100644 --- a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts @@ -1,80 +1,81 @@ import AdmZip from 'adm-zip'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { + CommonCartridgeElementType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../common-cartridge.enums'; import { CommonCartridgeElementFactory, CommonCartridgeElementProps, } from '../elements/common-cartridge-element-factory'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../interfaces'; +import { MissingMetadataLoggableException } from '../errors'; +import { CommonCartridgeElement } from '../interfaces'; import { CommonCartridgeResourceFactory } from '../resources/common-cartridge-resource-factory'; -import { OmitVersion } from '../utils'; import { - CommonCartridgeOrganizationBuilder, - CommonCartridgeOrganizationBuilderOptions, -} from './common-cartridge-organization-builder'; + CommonCartridgeOrganizationNode, + CommonCartridgeOrganizationNodeProps, +} from './common-cartridge-organization-node'; +import { CommonCartridgeResourceCollectionBuilder } from './common-cartridge-resource-collection-builder'; export type CommonCartridgeFileBuilderProps = { version: CommonCartridgeVersion; identifier: string; }; -export class CommonCartridgeFileBuilder { - private readonly archive: AdmZip = new AdmZip(); +export type CommonCartridgeOrganizationProps = Omit; - private readonly organizationBuilders = new Array(); +export class CommonCartridgeFileBuilder { + private readonly resourcesBuilder: CommonCartridgeResourceCollectionBuilder = + new CommonCartridgeResourceCollectionBuilder(); - private readonly resources = new Array(); + private readonly organizationsRoot: CommonCartridgeOrganizationNode[] = []; - private metadata?: CommonCartridgeElement; + private metadataElement: CommonCartridgeElement | null = null; constructor(private readonly props: CommonCartridgeFileBuilderProps) {} - public addMetadata(props: CommonCartridgeElementProps): CommonCartridgeFileBuilder { - this.metadata = CommonCartridgeElementFactory.createElement({ + public addMetadata(metadataProps: CommonCartridgeElementProps): void { + this.metadataElement = CommonCartridgeElementFactory.createElement({ version: this.props.version, - ...props, + ...metadataProps, }); - - return this; } - public addOrganization( - props: OmitVersion - ): CommonCartridgeOrganizationBuilder { - const builder = new CommonCartridgeOrganizationBuilder( - { ...props, version: this.props.version }, - (resource: CommonCartridgeResource) => this.resources.push(resource) + public createOrganization(organizationProps: CommonCartridgeOrganizationProps): CommonCartridgeOrganizationNode { + const organization = new CommonCartridgeOrganizationNode( + { ...organizationProps, version: this.props.version, type: CommonCartridgeElementType.ORGANIZATION }, + this.resourcesBuilder, + null ); - this.organizationBuilders.push(builder); + this.organizationsRoot.push(organization); - return builder; + return organization; } - public async build(): Promise { - if (!this.metadata) { - throw new Error('Metadata is not defined'); + public build(): Buffer { + if (!this.metadataElement) { + throw new MissingMetadataLoggableException(); } - const organizations = this.organizationBuilders.map((builder) => builder.build()); + const archive = new AdmZip(); + const organizations = this.organizationsRoot.map((organization) => organization.build()); + const resources = this.resourcesBuilder.build(); const manifest = CommonCartridgeResourceFactory.createResource({ type: CommonCartridgeResourceType.MANIFEST, version: this.props.version, identifier: this.props.identifier, - metadata: this.metadata, + metadata: this.metadataElement, organizations, - resources: this.resources, + resources, }); - for (const resources of this.resources) { - if (!resources.canInline()) { - this.archive.addFile(resources.getFilePath(), Buffer.from(resources.getFileContent())); - } - } - - this.archive.addFile(manifest.getFilePath(), Buffer.from(manifest.getFileContent())); + archive.addFile(manifest.getFilePath(), Buffer.from(manifest.getFileContent())); - const buffer = await this.archive.toBufferPromise(); + resources.forEach((resource) => { + archive.addFile(resource.getFilePath(), Buffer.from(resource.getFileContent())); + }); - return buffer; + return archive.toBuffer(); } } diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts deleted file mode 100644 index ba9a36001ae..00000000000 --- a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { faker } from '@faker-js/faker/locale/af_ZA'; -import { createCommonCartridgeWebContentResourcePropsV110 } from '../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../common-cartridge.enums'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../interfaces'; -import { - CommonCartridgeOrganizationBuilder, - CommonCartridgeOrganizationBuilderOptions, -} from './common-cartridge-organization-builder'; - -describe('CommonCartridgeOrganizationBuilder', () => { - describe('build', () => { - describe('when building a Common Cartridge organization with resources', () => { - const setup = () => { - const resources = new Array(); - - const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { - identifier: faker.string.uuid(), - title: faker.lorem.words(), - }; - - const resourceProps = createCommonCartridgeWebContentResourcePropsV110(); - - const sut = new CommonCartridgeOrganizationBuilder( - { - ...organizationOptions, - version: CommonCartridgeVersion.V_1_1_0, - }, - (resource) => resources.push(resource) - ) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps); - - return { sut, resources }; - }; - - it('should return a common cartridge element', () => { - const { sut, resources } = setup(); - - const element = sut.build(); - - expect(element).toBeInstanceOf(CommonCartridgeElement); - expect(resources.length).toBe(3); - }); - }); - - describe('when building a Common Cartridge organization with items', () => { - const setup = () => { - const resources = new Array(); - - const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { - identifier: faker.string.uuid(), - title: faker.lorem.words(), - }; - - const resourceProps = createCommonCartridgeWebContentResourcePropsV110(); - - const sut = new CommonCartridgeOrganizationBuilder( - { - ...organizationOptions, - version: CommonCartridgeVersion.V_1_1_0, - }, - (resource) => resources.push(resource) - ) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps) - .addSubOrganization(organizationOptions) - .addResource(resourceProps) - .addResource(resourceProps); - - return { sut, resources }; - }; - - it('should return a common cartridge element', () => { - const { sut, resources } = setup(); - - const element = sut.build(); - - expect(element).toBeInstanceOf(CommonCartridgeElement); - expect(resources.length).toBe(4); - }); - }); - }); -}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts deleted file mode 100644 index 20ff6d3e528..00000000000 --- a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CommonCartridgeElementType, CommonCartridgeVersion } from '../common-cartridge.enums'; -import { CommonCartridgeElementFactory } from '../elements/common-cartridge-element-factory'; -import { CommonCartridgeElement } from '../interfaces/common-cartridge-element.interface'; -import { CommonCartridgeResource } from '../interfaces/common-cartridge-resource.interface'; -import { - CommonCartridgeResourceFactory, - CommonCartridgeResourceProps, -} from '../resources/common-cartridge-resource-factory'; -import { OmitVersionAndFolder } from '../utils'; - -export type CommonCartridgeOrganizationBuilderOptions = - OmitVersionAndFolder; - -type CommonCartridgeOrganizationBuilderOptionsInternal = { - version: CommonCartridgeVersion; - identifier: string; - title: string; - folder?: string; -}; - -export class CommonCartridgeOrganizationBuilder { - private readonly resources: CommonCartridgeResource[] = []; - - private readonly subOrganizations: CommonCartridgeOrganizationBuilder[] = []; - - constructor( - protected readonly options: CommonCartridgeOrganizationBuilderOptionsInternal, - private readonly addResourceToFileBuilder: (resource: CommonCartridgeResource) => void - ) {} - - private get folder(): string { - return this.options.folder ? `${this.options.folder}/${this.options.identifier}` : this.options.identifier; - } - - public addSubOrganization( - options: OmitVersionAndFolder - ): CommonCartridgeOrganizationBuilder { - const subOrganization = new CommonCartridgeOrganizationBuilder( - { ...options, version: this.options.version, folder: this.folder }, - (resource: CommonCartridgeResource) => this.addResourceToFileBuilder(resource) - ); - - this.subOrganizations.push(subOrganization); - - return subOrganization; - } - - public addResource(props: CommonCartridgeResourceProps): CommonCartridgeOrganizationBuilder { - const resource = CommonCartridgeResourceFactory.createResource({ - version: this.options.version, - folder: this.folder, - ...props, - }); - - this.resources.push(resource); - this.addResourceToFileBuilder(resource); - - return this; - } - - public build(): CommonCartridgeElement { - const organizationElement = CommonCartridgeElementFactory.createElement({ - type: CommonCartridgeElementType.ORGANIZATION, - version: this.options.version, - identifier: this.options.identifier, - title: this.options.title, - items: this.buildItems(), - }); - - return organizationElement; - } - - private buildItems(): (CommonCartridgeElement | CommonCartridgeResource)[] { - if (this.resources.length === 1 && this.subOrganizations.length === 0) { - return [...this.resources]; - } - - const items = [...this.resources, ...this.subOrganizations.map((subOrganization) => subOrganization.build())]; - - return items; - } -} diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.spec.ts new file mode 100644 index 00000000000..204ae5d1685 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.spec.ts @@ -0,0 +1,157 @@ +import { createMock } from '@golevelup/ts-jest'; +import { createCommonCartridgeOrganizationNodeProps } from '../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWebLinkResourceProps } from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeElement } from '../interfaces'; +import { CommonCartridgeOrganizationNode } from './common-cartridge-organization-node'; +import { CommonCartridgeResourceCollectionBuilder } from './common-cartridge-resource-collection-builder'; + +describe('CommonCartridgeOrganizationNode', () => { + const setupOrganizationNodeProps = () => { + const props = createCommonCartridgeOrganizationNodeProps(); + + return props; + }; + const setupResourcesMock = () => { + const mock = createMock(); + + return mock; + }; + const setupResourceProps = () => { + const resourceProps = createCommonCartridgeWebLinkResourceProps(); + + return resourceProps; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('folder', () => { + describe('when organization node has no parent', () => { + const setup = () => { + const resourcesMock = setupResourcesMock(); + const props = setupOrganizationNodeProps(); + const sut = new CommonCartridgeOrganizationNode(props, resourcesMock, null); + + return { sut, props }; + }; + + it('should return its own id as folder', () => { + const { sut, props } = setup(); + + const result = sut.folder; + + expect(result).toBe(props.identifier); + }); + }); + + describe('when organization node has parent', () => { + // AI next 15 lines + const setup = () => { + const resourcesMock = setupResourcesMock(); + const props = setupOrganizationNodeProps(); + const parentProps = setupOrganizationNodeProps(); + const parent = new CommonCartridgeOrganizationNode(parentProps, resourcesMock, null); + const sut = new CommonCartridgeOrganizationNode(props, resourcesMock, parent); + + return { sut, props, parentProps }; + }; + + it('should construct folder path from parent and own identifier', () => { + const { sut, props, parentProps } = setup(); + + const result = sut.folder; + + expect(result).toBe(`${parentProps.identifier}/${props.identifier}`); + }); + }); + }); + + describe('createChild', () => { + describe('when creating a child organization node', () => { + const setup = () => { + const resourcesMock = setupResourcesMock(); + const childrenMock = createMock(); + const props = setupOrganizationNodeProps(); + const childProps = setupOrganizationNodeProps(); + const sut = new CommonCartridgeOrganizationNode(props, resourcesMock, null); + + Reflect.set(sut, 'children', childrenMock); + + return { sut, childProps, childrenMock }; + }; + + it('should return a new organization node', () => { + const { sut, childProps } = setup(); + + const result = sut.createChild(childProps); + + expect(result).toBeInstanceOf(CommonCartridgeOrganizationNode); + expect(result).not.toBe(sut); + }); + + it('should add new organization node to children', () => { + const { sut, childProps, childrenMock } = setup(); + + const result = sut.createChild(childProps); + + expect(result).toBeDefined(); + expect(childrenMock.push).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('addResource', () => { + describe('when adding a resource to an organization node', () => { + const setup = () => { + const resourcesMock = setupResourcesMock(); + const childrenMock = createMock(); + const props = setupOrganizationNodeProps(); + const resourceProps = setupResourceProps(); + const sut = new CommonCartridgeOrganizationNode(props, resourcesMock, null); + + return { sut, resourceProps, childrenMock, resourcesMock }; + }; + + it('should call addResource on resource collection builder', () => { + const { sut, resourceProps, resourcesMock } = setup(); + + sut.addResource(resourceProps); + + expect(resourcesMock.addResource).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('build', () => { + describe('when building an organization node', () => { + const setup = () => { + const resourcesMock = setupResourcesMock(); + const props = setupOrganizationNodeProps(); + const sut = new CommonCartridgeOrganizationNode(props, resourcesMock, null); + const childProps = setupOrganizationNodeProps(); + const childNode = sut.createChild(childProps); + const childNodeBuildSpy = jest.spyOn(childNode, 'build'); + + return { sut, childNodeBuildSpy }; + }; + + it('should return an common cartridge element', () => { + const { sut } = setup(); + + const result = sut.build(); + + expect(result).toBeInstanceOf(CommonCartridgeElement); + }); + + it('should build all children', () => { + const { sut, childNodeBuildSpy } = setup(); + + const result = sut.build(); + + expect(result).toBeDefined(); + expect(childNodeBuildSpy).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.ts new file mode 100644 index 00000000000..9185d9db589 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-node.ts @@ -0,0 +1,71 @@ +import { CommonCartridgeElementType } from '../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../elements/common-cartridge-element-factory'; +import { CommonCartridgeOrganizationElementPropsV110 } from '../elements/v1.1.0'; +import { CommonCartridgeOrganizationElementPropsV130 } from '../elements/v1.3.0'; +import { CommonCartridgeElement } from '../interfaces'; +import { CommonCartridgeResourceProps } from '../resources/common-cartridge-resource-factory'; +import type { CommonCartridgeOrganizationProps } from './common-cartridge-file-builder'; +import { CommonCartridgeResourceCollectionBuilder } from './common-cartridge-resource-collection-builder'; +import { CommonCartridgeResourceNode } from './common-cartridge-resource-node'; + +export type CommonCartridgeOrganizationNodeProps = Omit< + CommonCartridgeOrganizationElementPropsV110 | CommonCartridgeOrganizationElementPropsV130, + 'items' +>; + +export class CommonCartridgeOrganizationNode { + private readonly parent: CommonCartridgeOrganizationNode | null = null; + + private readonly children: (CommonCartridgeOrganizationNode | CommonCartridgeResourceNode)[] = []; + + constructor( + private readonly props: CommonCartridgeOrganizationNodeProps, + private readonly resourcesBuilder: CommonCartridgeResourceCollectionBuilder, + parent: CommonCartridgeOrganizationNode | null + ) { + this.parent = parent; + } + + public get folder(): string { + return this.parent ? `${this.parent.folder}/${this.props.identifier}` : this.props.identifier; + } + + public createChild(childProps: CommonCartridgeOrganizationProps): CommonCartridgeOrganizationNode { + const organization = new CommonCartridgeOrganizationNode( + { + ...childProps, + version: this.props.version, + type: CommonCartridgeElementType.ORGANIZATION, + }, + this.resourcesBuilder, + this + ); + + this.children.push(organization); + + return organization; + } + + public addResource(resourceProps: CommonCartridgeResourceProps): void { + const resource = new CommonCartridgeResourceNode( + { + ...resourceProps, + version: this.props.version, + }, + this + ); + + this.children.push(resource); + this.resourcesBuilder.addResource(resource); + } + + public build(): CommonCartridgeElement { + const organization = CommonCartridgeElementFactory.createElement({ + ...this.props, + version: this.props.version, + items: this.children.map((child) => child.build()), + }); + + return organization; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.spec.ts new file mode 100644 index 00000000000..c7c6d1e8020 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.spec.ts @@ -0,0 +1,74 @@ +import { createMock } from '@golevelup/ts-jest'; +import { createCommonCartridgeOrganizationNodeProps } from '../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWebContentResourceProps } from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeOrganizationNode } from './common-cartridge-organization-node'; +import { CommonCartridgeResourceCollectionBuilder } from './common-cartridge-resource-collection-builder'; +import { CommonCartridgeResourceNode, CommonCartridgeResourceNodeProps } from './common-cartridge-resource-node'; + +describe('CommonCartridgeResourceCollectionBuilder', () => { + let sut: CommonCartridgeResourceCollectionBuilder; + + const setupResourceNode = () => { + const resourceNodeProps: CommonCartridgeResourceNodeProps = { + ...createCommonCartridgeWebContentResourceProps(), + version: CommonCartridgeVersion.V_1_1_0, + }; + const organizationNodeProps = createCommonCartridgeOrganizationNodeProps(); + const organizationNode = new CommonCartridgeOrganizationNode(organizationNodeProps, sut, null); + const resourceNode = new CommonCartridgeResourceNode(resourceNodeProps, organizationNode); + + return resourceNode; + }; + + beforeEach(() => { + sut = new CommonCartridgeResourceCollectionBuilder(); + jest.clearAllMocks(); + }); + + describe('addResource', () => { + describe('when a resource is added to the CommonCartridgeResourceCollectionBuilder', () => { + const setup = () => { + const resourceNode = setupResourceNode(); + const resourceNodesMock = createMock(); + + Reflect.set(sut, 'resourceNodes', resourceNodesMock); + + return { resourceNode, resourceNodesMock }; + }; + + it('should add the resource node to the collection', () => { + const { resourceNode, resourceNodesMock } = setup(); + + sut.addResource(resourceNode); + + expect(resourceNodesMock.push).toHaveBeenCalledTimes(1); + expect(resourceNodesMock.push).toHaveBeenCalledWith(resourceNode); + }); + }); + }); + + describe('build', () => { + describe('when build method is called', () => { + const setup = () => { + const resourceNode1 = setupResourceNode(); + const resourceNode2 = setupResourceNode(); + + return { resourceNode1, resourceNode2 }; + }; + + it('should return the resource collection', () => { + const { resourceNode1, resourceNode2 } = setup(); + + sut.addResource(resourceNode1); + sut.addResource(resourceNode2); + + const resources = sut.build(); + + expect(resources).toHaveLength(2); + expect(resources).toContainEqual(resourceNode1.build()); + expect(resources).toContainEqual(resourceNode2.build()); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.ts new file mode 100644 index 00000000000..62b4b082480 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-collection-builder.ts @@ -0,0 +1,16 @@ +import { CommonCartridgeResource } from '../interfaces'; +import { CommonCartridgeResourceNode } from './common-cartridge-resource-node'; + +export class CommonCartridgeResourceCollectionBuilder { + private readonly resourceNodes: CommonCartridgeResourceNode[] = []; + + public addResource(resourceNode: CommonCartridgeResourceNode): void { + this.resourceNodes.push(resourceNode); + } + + public build(): CommonCartridgeResource[] { + const resources = this.resourceNodes.map((resource) => resource.build()); + + return resources; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.spec.ts new file mode 100644 index 00000000000..4cf86f6bb60 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.spec.ts @@ -0,0 +1,46 @@ +import { createCommonCartridgeOrganizationNodeProps } from '../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWebLinkResourceProps } from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeResource } from '../interfaces'; +import { CommonCartridgeOrganizationNode } from './common-cartridge-organization-node'; +import { CommonCartridgeResourceCollectionBuilder } from './common-cartridge-resource-collection-builder'; +import { CommonCartridgeResourceNode, CommonCartridgeResourceNodeProps } from './common-cartridge-resource-node'; + +describe('CommonCartridgeResourceNode', () => { + let sut: CommonCartridgeResourceNode; + + describe('build', () => { + describe('when build is called', () => { + const setup = () => { + const resourceNodeProps: CommonCartridgeResourceNodeProps = { + ...createCommonCartridgeWebLinkResourceProps(), + version: CommonCartridgeVersion.V_1_1_0, + }; + + const organizationNodeProps = createCommonCartridgeOrganizationNodeProps(); + + const resourceCollectionBuilder: CommonCartridgeResourceCollectionBuilder = + new CommonCartridgeResourceCollectionBuilder(); + + const organizationNode = new CommonCartridgeOrganizationNode( + organizationNodeProps, + resourceCollectionBuilder, + null + ); + + return { resourceNodeProps, organizationNode }; + }; + + it('should return a CommonCartridgeResource', () => { + const { resourceNodeProps, organizationNode } = setup(); + + sut = new CommonCartridgeResourceNode(resourceNodeProps, organizationNode); + + const result = sut.build(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(CommonCartridgeResource); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.ts new file mode 100644 index 00000000000..912c969a59a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-resource-node.ts @@ -0,0 +1,23 @@ +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeResource } from '../interfaces'; +import { + CommonCartridgeResourceFactory, + CommonCartridgeResourceProps, +} from '../resources/common-cartridge-resource-factory'; +import type { CommonCartridgeOrganizationNode } from './common-cartridge-organization-node'; + +export type CommonCartridgeResourceNodeProps = CommonCartridgeResourceProps & { version: CommonCartridgeVersion }; + +export class CommonCartridgeResourceNode { + private readonly parent: CommonCartridgeOrganizationNode; + + constructor(private readonly props: CommonCartridgeResourceNodeProps, parent: CommonCartridgeOrganizationNode) { + this.parent = parent; + } + + public build(): CommonCartridgeResource { + const resource = CommonCartridgeResourceFactory.createResource({ ...this.props, folder: this.parent.folder }); + + return resource; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts b/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts index 8e474d7c3df..183cc0f31d4 100644 --- a/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts +++ b/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts @@ -21,8 +21,10 @@ export enum CommonCartridgeIntendedUseType { } export enum CommonCartridgeElementType { + MANIFEST = 'manifest', METADATA = 'metadata', ORGANIZATION = 'organization', - RESOURCES_WRAPPER = 'resourceswrapper', - ORGANIZATIONS_WRAPPER = 'organizationswrapper', + ORGANIZATIONS_WRAPPER = 'organizations-wrapper', + RESOURCES_WRAPPER = 'resources-wrapper', + RESOURCE = 'resource', } diff --git a/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.spec.ts b/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.spec.ts new file mode 100644 index 00000000000..0739b6307b3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.spec.ts @@ -0,0 +1,29 @@ +import { CommonCartridgeGuard } from './common-cartridge.guard'; + +describe('CommonCartridgeGuard', () => { + describe('checkIntendedUse', () => { + describe('when intended use is supported', () => { + const supportedIntendedUses = ['use1', 'use2', 'use3']; + + it('should not throw an exception', () => { + const intendedUse = 'use1'; + + expect(() => { + CommonCartridgeGuard.checkIntendedUse(intendedUse, supportedIntendedUses); + }).not.toThrow(); + }); + }); + + describe('when intended use is not supported', () => { + const supportedIntendedUses = ['use1', 'use2', 'use3']; + + it('should throw an exception', () => { + const intendedUse = 'use4'; + + expect(() => { + CommonCartridgeGuard.checkIntendedUse(intendedUse, supportedIntendedUses); + }).toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.ts b/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.ts new file mode 100644 index 00000000000..2b0a012cef6 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/common-cartridge.guard.ts @@ -0,0 +1,9 @@ +import { IntendedUseNotSupportedLoggableException } from './errors'; + +export class CommonCartridgeGuard { + public static checkIntendedUse(intendedUse: string, supportedIntendedUses: string[]): void { + if (!supportedIntendedUses.includes(intendedUse)) { + throw new IntendedUseNotSupportedLoggableException(intendedUse); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-organizations-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-organizations-wrapper-element.ts new file mode 100644 index 00000000000..e30c80e0d87 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-organizations-wrapper-element.ts @@ -0,0 +1,53 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, XmlObject } from '../../interfaces'; + +export type CommonCartridgeOrganizationsWrapperElementProps = { + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +/** + * This abstract class was created to reduce code duplication and + * keep the SonarCloud code duplication rate below 3%. + */ +export abstract class CommonCartridgeOrganizationsWrapperElement extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeOrganizationsWrapperElementProps) { + super(props); + } + + abstract getSupportedVersion(): CommonCartridgeVersion; + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.ORGANIZATIONS_WRAPPER: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } + } + + private getManifestXmlObjectInternal(): XmlObject { + return { + organization: [ + { + $: { + identifier: 'org-1', + structure: 'rooted-hierarchy', + }, + item: [ + { + $: { + identifier: 'LearningModules', + }, + item: this.props.items.map((items) => + items.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION) + ), + }, + ], + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-resources-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-resources-wrapper-element.ts new file mode 100644 index 00000000000..06059be68ea --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/abstract/common-cartridge-resources-wrapper-element.ts @@ -0,0 +1,40 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, XmlObject } from '../../interfaces'; + +export type CommonCartridgeResourcesWrapperElementProps = { + type: CommonCartridgeElementType.RESOURCES_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +/** + * This abstract class was created to reduce code duplication and + * keep the SonarCloud code duplication rate below 3%. + */ +export abstract class CommonCartridgeResourcesWrapperElement extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeResourcesWrapperElementProps) { + super(props); + } + + abstract getSupportedVersion(): CommonCartridgeVersion; + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.RESOURCES_WRAPPER: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } + } + + private getManifestXmlObjectInternal(): XmlObject { + return { + resources: [ + { + resource: this.props.items.map((items) => items.getManifestXmlObject(CommonCartridgeElementType.RESOURCE)), + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts index b891d1aa49a..2aad5899e57 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeMetadataElementPropsV110, createCommonCartridgeMetadataElementPropsV130, } from '../../testing/common-cartridge-element-props.factory'; import { CommonCartridgeElementType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { VersionNotSupportedLoggableException } from '../errors'; import { CommonCartridgeElementFactory } from './common-cartridge-element-factory'; import { CommonCartridgeMetadataElementPropsV110, CommonCartridgeMetadataElementV110 } from './v1.1.0'; import { CommonCartridgeMetadataElementV130 } from './v1.3.0'; @@ -36,14 +36,14 @@ describe('CommonCartridgeElementFactory', () => { CommonCartridgeVersion.V_1_4_0, ]; - it('should throw InternalServerErrorException', () => { + it('should throw VersionNotSupportedLoggableException', () => { notSupportedVersions.forEach((version) => { expect(() => CommonCartridgeElementFactory.createElement({ version, type: CommonCartridgeElementType.METADATA, } as CommonCartridgeMetadataElementPropsV110) - ).toThrow(InternalServerErrorException); + ).toThrow(VersionNotSupportedLoggableException); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts index 046f7033fde..7765e79d7ac 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts @@ -1,6 +1,7 @@ import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { VersionNotSupportedLoggableException } from '../errors'; import { CommonCartridgeElement } from '../interfaces'; -import { OmitVersionAndFolder, createVersionNotSupportedError } from '../utils'; +import { OmitVersionAndFolder } from '../utils'; import { CommonCartridgeElementFactoryV110, CommonCartridgeMetadataElementPropsV110, @@ -46,7 +47,7 @@ export class CommonCartridgeElementFactory { case CommonCartridgeVersion.V_1_3_0: return CommonCartridgeElementFactoryV130.createElement(props); default: - throw createVersionNotSupportedError(version); + throw new VersionNotSupportedLoggableException(version); } } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts index 3c43cf3a830..3c4fceabe83 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts @@ -1,4 +1,3 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeMetadataElementPropsV110, createCommonCartridgeOrganizationElementPropsV110, @@ -6,6 +5,7 @@ import { createCommonCartridgeResourcesWrapperElementPropsV110, } from '../../../testing/common-cartridge-element-props.factory'; import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElementFactoryV110 } from './common-cartridge-element-factory'; import { CommonCartridgeMetadataElementV110 } from './common-cartridge-metadata-element'; import { CommonCartridgeOrganizationElementV110 } from './common-cartridge-organization-element'; @@ -53,9 +53,9 @@ describe('CommonCartridgeElementFactoryV110', () => { notSupportedProps.type = 'not-supported' as CommonCartridgeElementType.METADATA; - it('should throw error', () => { + it('should throw ElementTypeNotSupportedLoggableException', () => { expect(() => CommonCartridgeElementFactoryV110.createElement(notSupportedProps)).toThrow( - InternalServerErrorException + ElementTypeNotSupportedLoggableException ); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts index 88095cf218a..b623cd277f8 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts @@ -1,6 +1,6 @@ import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElement } from '../../interfaces'; -import { createElementTypeNotSupportedError } from '../../utils'; import { CommonCartridgeMetadataElementPropsV110, CommonCartridgeMetadataElementV110, @@ -39,7 +39,7 @@ export class CommonCartridgeElementFactoryV110 { case CommonCartridgeElementType.RESOURCES_WRAPPER: return new CommonCartridgeResourcesWrapperElementV110(props); default: - throw createElementTypeNotSupportedError(type); + throw new ElementTypeNotSupportedLoggableException(type); } } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts index cd12efa1590..1ab4e0421c4 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts @@ -1,6 +1,6 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeMetadataElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeMetadataElementV110 } from './common-cartridge-metadata-element'; describe('CommonCartridgeMetadataElementV110', () => { @@ -27,13 +27,15 @@ describe('CommonCartridgeMetadataElementV110', () => { notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; it('should throw error', () => { - expect(() => new CommonCartridgeMetadataElementV110(notSupportedProps)).toThrow(InternalServerErrorException); + expect(() => new CommonCartridgeMetadataElementV110(notSupportedProps)).toThrow( + VersionNotSupportedLoggableException + ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.1', () => { + describe('when creating metadata xml object', () => { const setup = () => { const props = createCommonCartridgeMetadataElementPropsV110(); const sut = new CommonCartridgeMetadataElementV110(props); @@ -41,10 +43,10 @@ describe('CommonCartridgeMetadataElementV110', () => { return { sut, props }; }; - it('should return correct manifest xml object', () => { + it('should return metadata manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.METADATA); expect(result).toStrictEqual({ schema: 'IMS Common Cartridge', @@ -67,5 +69,21 @@ describe('CommonCartridgeMetadataElementV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeMetadataElementPropsV110(); + const sut = new CommonCartridgeMetadataElementV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts index c70cbd2c892..f51b842e011 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts @@ -1,5 +1,6 @@ import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, XmlObject } from '../../interfaces'; export type CommonCartridgeMetadataElementPropsV110 = { type: CommonCartridgeElementType.METADATA; @@ -18,7 +19,16 @@ export class CommonCartridgeMetadataElementV110 extends CommonCartridgeElement { return CommonCartridgeVersion.V_1_1_0; } - public getManifestXmlObject(): Record { + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.METADATA: + return this.getManifestMetadataXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } + } + + private getManifestMetadataXmlObjectInternal() { return { schema: 'IMS Common Cartridge', schemaversion: '1.1.0', diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts index 8e1be0c920f..f42cc140d5d 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts @@ -1,7 +1,7 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeOrganizationElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; import { CommonCartridgeOrganizationElementV110 } from './common-cartridge-organization-element'; @@ -31,25 +31,20 @@ describe('CommonCartridgeOrganizationElementV110', () => { it('should throw error', () => { expect(() => new CommonCartridgeOrganizationElementV110(notSupportedProps)).toThrowError( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.1.0', () => { + describe('when creating organization xml object', () => { const setup = () => { const resourceProps = createCommonCartridgeWeblinkResourcePropsV110(); - - const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV110( - CommonCartridgeResourceFactory.createResource(resourceProps) - ); - + const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV110(); const subOrganization2Props = createCommonCartridgeOrganizationElementPropsV110([ CommonCartridgeResourceFactory.createResource(resourceProps), ]); - const organizationProps = createCommonCartridgeOrganizationElementPropsV110([ CommonCartridgeElementFactory.createElement(subOrganization1Props), CommonCartridgeElementFactory.createElement(subOrganization2Props), @@ -60,10 +55,10 @@ describe('CommonCartridgeOrganizationElementV110', () => { return { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps }; }; - it('should return correct manifest xml object', () => { + it('should return organization manifest fragment', () => { const { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); expect(result).toStrictEqual({ $: { @@ -74,9 +69,9 @@ describe('CommonCartridgeOrganizationElementV110', () => { { $: { identifier: subOrganization1Props.identifier, - identifierref: resourceProps.identifier, }, title: subOrganization1Props.title, + item: [], }, { $: { @@ -97,5 +92,23 @@ describe('CommonCartridgeOrganizationElementV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeOrganizationElementPropsV110(); + const sut = new CommonCartridgeOrganizationElementV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrowError( + ElementTypeNotSupportedLoggableException + ); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts index ff304d6ea08..215f68d7a38 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts @@ -1,13 +1,13 @@ import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; -import { createIdentifier } from '../../utils'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, CommonCartridgeResource, XmlObject } from '../../interfaces'; export type CommonCartridgeOrganizationElementPropsV110 = { type: CommonCartridgeElementType.ORGANIZATION; version: CommonCartridgeVersion; identifier: string; title: string; - items: CommonCartridgeResource | Array; + items: CommonCartridgeResource | Array; }; export class CommonCartridgeOrganizationElementV110 extends CommonCartridgeElement { @@ -19,35 +19,32 @@ export class CommonCartridgeOrganizationElementV110 extends CommonCartridgeEleme return CommonCartridgeVersion.V_1_1_0; } - public getManifestXmlObject(): Record { - if (this.props.items instanceof CommonCartridgeResource) { - return { - $: { - identifier: this.identifier, - identifierref: this.props.items.identifier, - }, - title: this.title, - }; + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); } + } + + private getManifestXmlObjectInternal(): XmlObject { + const xmlObject = Array.isArray(this.props.items) + ? this.getManifestXmlObjectForMany(this.props.items) + : this.props.items.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); - return { + return xmlObject; + } + + private getManifestXmlObjectForMany(items: Array): XmlObject { + const xmlObject = { $: { identifier: this.identifier, }, title: this.title, - item: this.props.items.map((item) => { - if (item instanceof CommonCartridgeResource) { - return { - $: { - identifier: createIdentifier(), - identifierref: item.identifier, - }, - title: item.title, - }; - } - - return item.getManifestXmlObject(); - }), + item: items.map((item) => item.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION)), }; + + return xmlObject; } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts index fb5ae465a28..2efc3000a3c 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeOrganizationElementPropsV110, createCommonCartridgeOrganizationsWrapperElementPropsV110, } from '../../../testing/common-cartridge-element-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; import { CommonCartridgeOrganizationsWrapperElementV110 } from './common-cartridge-organizations-wrapper-element'; @@ -31,15 +31,15 @@ describe('CommonCartridgeOrganizationsWrapperElementV110', () => { notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; it('should throw error', () => { - expect(() => new CommonCartridgeOrganizationsWrapperElementV110(notSupportedProps)).toThrowError( - InternalServerErrorException + expect(() => new CommonCartridgeOrganizationsWrapperElementV110(notSupportedProps)).toThrow( + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.1.0', () => { + describe('when creating organization wrapper xml object', () => { const setup = () => { const organizationProps = createCommonCartridgeOrganizationElementPropsV110(); const props = createCommonCartridgeOrganizationsWrapperElementPropsV110([ @@ -50,10 +50,10 @@ describe('CommonCartridgeOrganizationsWrapperElementV110', () => { return { sut, organizationProps }; }; - it('should return correct manifest xml object', () => { + it('should return organization wrapper manifest fragment', () => { const { sut, organizationProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATIONS_WRAPPER); expect(result).toStrictEqual({ organization: [ @@ -83,5 +83,21 @@ describe('CommonCartridgeOrganizationsWrapperElementV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeOrganizationsWrapperElementPropsV110(); + const sut = new CommonCartridgeOrganizationsWrapperElementV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts index 9d1c44ec85d..564d010b79c 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts @@ -1,39 +1,13 @@ -import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { + CommonCartridgeOrganizationsWrapperElement, + CommonCartridgeOrganizationsWrapperElementProps, +} from '../abstract/common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; -export type CommonCartridgeOrganizationsWrapperElementPropsV110 = { - type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; - version: CommonCartridgeVersion; - items: CommonCartridgeElement[]; -}; - -export class CommonCartridgeOrganizationsWrapperElementV110 extends CommonCartridgeElement { - constructor(private readonly props: CommonCartridgeOrganizationsWrapperElementPropsV110) { - super(props); - } +export type CommonCartridgeOrganizationsWrapperElementPropsV110 = CommonCartridgeOrganizationsWrapperElementProps; +export class CommonCartridgeOrganizationsWrapperElementV110 extends CommonCartridgeOrganizationsWrapperElement { public getSupportedVersion(): CommonCartridgeVersion { return CommonCartridgeVersion.V_1_1_0; } - - public getManifestXmlObject(): Record { - return { - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: this.props.items.map((items) => items.getManifestXmlObject()), - }, - ], - }, - ], - }; - } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts index 0106eefbee5..ed54df3df8e 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts @@ -1,7 +1,7 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeResourcesWrapperElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; import { CommonCartridgeResourcesWrapperElementV110 } from './common-cartridge-resources-wrapper-element'; @@ -30,14 +30,14 @@ describe('CommonCartridgeResourcesWrapperElementV110', () => { it('should throw error', () => { expect(() => new CommonCartridgeResourcesWrapperElementV110(notSupportedProps)).toThrow( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using common cartridge version 1.1.0', () => { + describe('when creating resources wrapper xml object', () => { const setup = () => { const resourceProps = createCommonCartridgeWeblinkResourcePropsV110(); const props = createCommonCartridgeResourcesWrapperElementPropsV110([ @@ -48,10 +48,10 @@ describe('CommonCartridgeResourcesWrapperElementV110', () => { return { sut, resourceProps }; }; - it('should return correct manifest xml object', () => { + it('should return resources wrapper manifest fragment', () => { const { sut, resourceProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCES_WRAPPER); expect(result).toStrictEqual({ resources: [ @@ -74,5 +74,21 @@ describe('CommonCartridgeResourcesWrapperElementV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeResourcesWrapperElementPropsV110(); + const sut = new CommonCartridgeResourcesWrapperElementV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts index 4048787732a..e179834e99e 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts @@ -1,28 +1,13 @@ -import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { + CommonCartridgeResourcesWrapperElement, + CommonCartridgeResourcesWrapperElementProps, +} from '../abstract/common-cartridge-resources-wrapper-element'; -export type CommonCartridgeResourcesWrapperElementPropsV110 = { - type: CommonCartridgeElementType.RESOURCES_WRAPPER; - version: CommonCartridgeVersion; - items: CommonCartridgeElement[]; -}; - -export class CommonCartridgeResourcesWrapperElementV110 extends CommonCartridgeElement { - constructor(private readonly props: CommonCartridgeResourcesWrapperElementPropsV110) { - super(props); - } +export type CommonCartridgeResourcesWrapperElementPropsV110 = CommonCartridgeResourcesWrapperElementProps; +export class CommonCartridgeResourcesWrapperElementV110 extends CommonCartridgeResourcesWrapperElement { public getSupportedVersion(): CommonCartridgeVersion { return CommonCartridgeVersion.V_1_1_0; } - - public getManifestXmlObject(): Record { - return { - resources: [ - { - resource: this.props.items.map((items) => items.getManifestXmlObject()), - }, - ], - }; - } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts index 7fe5e1f0cf5..7a5cdae2204 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts @@ -1,4 +1,3 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeMetadataElementPropsV130, createCommonCartridgeOrganizationElementPropsV130, @@ -6,6 +5,7 @@ import { createCommonCartridgeResourcesWrapperElementPropsV130, } from '../../../testing/common-cartridge-element-props.factory'; import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElementFactoryV130 } from './common-cartridge-element-factory'; import { CommonCartridgeMetadataElementV130 } from './common-cartridge-metadata-element'; import { CommonCartridgeOrganizationElementV130 } from './common-cartridge-organization-element'; @@ -52,9 +52,9 @@ describe('CommonCartridgeElementFactoryV130', () => { const notSupportedProps = createCommonCartridgeOrganizationsWrapperElementPropsV130(); notSupportedProps.type = 'not-supported' as CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; - it('should throw error', () => { + it('should throw ElementTypeNotSupportedLoggableException', () => { expect(() => CommonCartridgeElementFactoryV130.createElement(notSupportedProps)).toThrow( - InternalServerErrorException + ElementTypeNotSupportedLoggableException ); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts index 6a1216e405a..adade3178cb 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts @@ -1,6 +1,6 @@ import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElement } from '../../interfaces'; -import { createElementTypeNotSupportedError } from '../../utils'; import { CommonCartridgeMetadataElementPropsV130, CommonCartridgeMetadataElementV130, @@ -39,7 +39,7 @@ export class CommonCartridgeElementFactoryV130 { case CommonCartridgeElementType.RESOURCES_WRAPPER: return new CommonCartridgeResourcesWrapperElementV130(props); default: - throw createElementTypeNotSupportedError(type); + throw new ElementTypeNotSupportedLoggableException(type); } } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts index 698b3efea04..e672a08d3d2 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts @@ -1,6 +1,6 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeMetadataElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeMetadataElementV130 } from './common-cartridge-metadata-element'; describe('CommonCartridgeMetadataElementV130', () => { @@ -27,13 +27,15 @@ describe('CommonCartridgeMetadataElementV130', () => { notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; it('should throw error', () => { - expect(() => new CommonCartridgeMetadataElementV130(notSupportedProps)).toThrow(InternalServerErrorException); + expect(() => new CommonCartridgeMetadataElementV130(notSupportedProps)).toThrow( + VersionNotSupportedLoggableException + ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using common cartridge version 1.3', () => { + describe('when creating metadata xml object', () => { const setup = () => { const props = createCommonCartridgeMetadataElementPropsV130(); const sut = new CommonCartridgeMetadataElementV130(props); @@ -41,10 +43,10 @@ describe('CommonCartridgeMetadataElementV130', () => { return { sut, props }; }; - it('should return correct manifest xml object', () => { + it('should return metadata manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.METADATA); expect(result).toStrictEqual({ schema: 'IMS Common Cartridge', @@ -67,5 +69,22 @@ describe('CommonCartridgeMetadataElementV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const props = createCommonCartridgeMetadataElementPropsV130(); + const sut = new CommonCartridgeMetadataElementV130(props); + + return { sut, props }; + }; + + it('should throw error', () => { + const { sut } = setup(); + + expect(() => sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION)).toThrow( + ElementTypeNotSupportedLoggableException + ); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts index a6477783e57..f7503c779dc 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts @@ -1,5 +1,6 @@ import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, XmlObject } from '../../interfaces'; export type CommonCartridgeMetadataElementPropsV130 = { type: CommonCartridgeElementType.METADATA; @@ -18,7 +19,16 @@ export class CommonCartridgeMetadataElementV130 extends CommonCartridgeElement { return CommonCartridgeVersion.V_1_3_0; } - public getManifestXmlObject(): Record { + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.METADATA: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } + } + + private getManifestXmlObjectInternal(): XmlObject { return { schema: 'IMS Common Cartridge', schemaversion: '1.3.0', diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts index 73cb9b1af5a..6ada585495b 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts @@ -1,7 +1,7 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeOrganizationElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; import { CommonCartridgeOrganizationElementV130 } from './common-cartridge-organization-element'; @@ -31,25 +31,20 @@ describe('CommonCartridgeOrganizationElementV130', () => { it('should throw error', () => { expect(() => new CommonCartridgeOrganizationElementV130(notSupportedProps)).toThrow( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using common cartridge version 1.3.0', () => { + describe('when creating organization xml object', () => { const setup = () => { const resourceProps = createCommonCartridgeWeblinkResourcePropsV130(); - - const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV130( - CommonCartridgeResourceFactory.createResource(resourceProps) - ); - + const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV130(); const subOrganization2Props = createCommonCartridgeOrganizationElementPropsV130([ CommonCartridgeResourceFactory.createResource(resourceProps), ]); - const organizationProps = createCommonCartridgeOrganizationElementPropsV130([ CommonCartridgeElementFactory.createElement(subOrganization1Props), CommonCartridgeElementFactory.createElement(subOrganization2Props), @@ -60,10 +55,10 @@ describe('CommonCartridgeOrganizationElementV130', () => { return { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps }; }; - it('should return correct manifest xml object', () => { + it('should return organization manifest fragment', () => { const { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); expect(result).toStrictEqual({ $: { @@ -74,9 +69,9 @@ describe('CommonCartridgeOrganizationElementV130', () => { { $: { identifier: subOrganization1Props.identifier, - identifierref: resourceProps.identifier, }, title: subOrganization1Props.title, + item: [], }, { $: { @@ -97,5 +92,21 @@ describe('CommonCartridgeOrganizationElementV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeOrganizationElementPropsV130(); + const sut = new CommonCartridgeOrganizationElementV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts index 14696edfd95..9c71e8b3e29 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts @@ -1,13 +1,13 @@ import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; -import { createIdentifier } from '../../utils'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, CommonCartridgeResource, XmlObject } from '../../interfaces'; export type CommonCartridgeOrganizationElementPropsV130 = { type: CommonCartridgeElementType.ORGANIZATION; version: CommonCartridgeVersion; identifier: string; title: string; - items: CommonCartridgeResource | Array; + items: CommonCartridgeResource | Array; }; export class CommonCartridgeOrganizationElementV130 extends CommonCartridgeElement { @@ -19,35 +19,32 @@ export class CommonCartridgeOrganizationElementV130 extends CommonCartridgeEleme return CommonCartridgeVersion.V_1_3_0; } - public getManifestXmlObject(): Record { - if (this.props.items instanceof CommonCartridgeResource) { - return { - $: { - identifier: this.identifier, - identifierref: this.props.items.identifier, - }, - title: this.title, - }; + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); } + } + + public getManifestXmlObjectInternal(): XmlObject { + const xmlObject = Array.isArray(this.props.items) + ? this.getManifestXmlObjectForCollection(this.props.items) + : this.props.items.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); - return { + return xmlObject; + } + + private getManifestXmlObjectForCollection(items: Array): XmlObject { + const xmlObject = { $: { identifier: this.identifier, }, title: this.title, - item: this.props.items.map((item) => { - if (item instanceof CommonCartridgeResource) { - return { - $: { - identifier: createIdentifier(), - identifierref: item.identifier, - }, - title: item.title, - }; - } - - return item.getManifestXmlObject(); - }), + item: items.map((item) => item.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION)), }; + + return xmlObject; } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts index 47c68046764..09cccd60b72 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeOrganizationElementPropsV130, createCommonCartridgeOrganizationsWrapperElementPropsV130, } from '../../../testing/common-cartridge-element-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; import { CommonCartridgeOrganizationsWrapperElementV130 } from './common-cartridge-organizations-wrapper-element'; @@ -32,14 +32,14 @@ describe('CommonCartridgeOrganizationsWrapperElementV130', () => { it('should throw error', () => { expect(() => new CommonCartridgeOrganizationsWrapperElementV130(notSupportedProps)).toThrow( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using common cartridge version 1.3.0', () => { + describe('when creating organizations wrapper xml object', () => { const setup = () => { const organizationProps = createCommonCartridgeOrganizationElementPropsV130(); const props = createCommonCartridgeOrganizationsWrapperElementPropsV130([ @@ -50,10 +50,10 @@ describe('CommonCartridgeOrganizationsWrapperElementV130', () => { return { sut, organizationProps }; }; - it('should return correct manifest xml object', () => { + it('should return organizations wrapper manifest fragment', () => { const { sut, organizationProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATIONS_WRAPPER); expect(result).toStrictEqual({ organization: [ @@ -83,5 +83,21 @@ describe('CommonCartridgeOrganizationsWrapperElementV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeOrganizationsWrapperElementPropsV130(); + const sut = new CommonCartridgeOrganizationsWrapperElementV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts index 3c00f4844d2..0bf03cd9efc 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts @@ -1,39 +1,13 @@ -import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { + CommonCartridgeOrganizationsWrapperElement, + CommonCartridgeOrganizationsWrapperElementProps, +} from '../abstract/common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; -export type CommonCartridgeOrganizationsWrapperElementPropsV130 = { - type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; - version: CommonCartridgeVersion; - items: CommonCartridgeElement[]; -}; - -export class CommonCartridgeOrganizationsWrapperElementV130 extends CommonCartridgeElement { - constructor(private readonly props: CommonCartridgeOrganizationsWrapperElementPropsV130) { - super(props); - } +export type CommonCartridgeOrganizationsWrapperElementPropsV130 = CommonCartridgeOrganizationsWrapperElementProps; +export class CommonCartridgeOrganizationsWrapperElementV130 extends CommonCartridgeOrganizationsWrapperElement { public getSupportedVersion(): CommonCartridgeVersion { return CommonCartridgeVersion.V_1_3_0; } - - public getManifestXmlObject(): Record { - return { - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: this.props.items.map((items) => items.getManifestXmlObject()), - }, - ], - }, - ], - }; - } } diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts index 2d71adcc144..17cdf2bac31 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts @@ -1,7 +1,7 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeResourcesWrapperElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; import { CommonCartridgeResourcesWrapperElementV130 } from './common-cartridge-resources-wrapper-element'; @@ -30,14 +30,14 @@ describe('CommonCartridgeResourcesWrapperElementV130', () => { it('should throw error', () => { expect(() => new CommonCartridgeResourcesWrapperElementV130(notSupportedProps)).toThrowError( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using common cartridge version 1.3.0', () => { + describe('when creating resources wrapper xml object', () => { const setup = () => { const weblinkResourceProps = createCommonCartridgeWeblinkResourcePropsV130(); const props = createCommonCartridgeResourcesWrapperElementPropsV130([ @@ -51,7 +51,7 @@ describe('CommonCartridgeResourcesWrapperElementV130', () => { it('should return correct manifest xml object', () => { const { sut, weblinkResourceProps } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCES_WRAPPER); expect(result).toStrictEqual({ resources: [ @@ -74,5 +74,23 @@ describe('CommonCartridgeResourcesWrapperElementV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeResourcesWrapperElementPropsV130(); + const sut = new CommonCartridgeResourcesWrapperElementV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrowError( + ElementTypeNotSupportedLoggableException + ); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts index aa11d0f457a..b9726dd3c63 100644 --- a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts @@ -1,28 +1,13 @@ -import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeElement } from '../../interfaces'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { + CommonCartridgeResourcesWrapperElement, + CommonCartridgeResourcesWrapperElementProps, +} from '../abstract/common-cartridge-resources-wrapper-element'; -export type CommonCartridgeResourcesWrapperElementPropsV130 = { - type: CommonCartridgeElementType.RESOURCES_WRAPPER; - version: CommonCartridgeVersion; - items: CommonCartridgeElement[]; -}; - -export class CommonCartridgeResourcesWrapperElementV130 extends CommonCartridgeElement { - constructor(private readonly props: CommonCartridgeResourcesWrapperElementPropsV130) { - super(props); - } +export type CommonCartridgeResourcesWrapperElementPropsV130 = CommonCartridgeResourcesWrapperElementProps; +export class CommonCartridgeResourcesWrapperElementV130 extends CommonCartridgeResourcesWrapperElement { public getSupportedVersion(): CommonCartridgeVersion { return CommonCartridgeVersion.V_1_3_0; } - - public getManifestXmlObject(): Record { - return { - resources: [ - { - resource: this.props.items.map((items) => items.getManifestXmlObject()), - }, - ], - }; - } } diff --git a/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.spec.ts b/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.spec.ts new file mode 100644 index 00000000000..65cd0f664d9 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.spec.ts @@ -0,0 +1,23 @@ +import { ElementTypeNotSupportedLoggableException } from './element-type-not-supported.loggable-exception'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +describe('ElementTypeNotSupportedLoggableException', () => { + describe('getLogMessage', () => { + describe('when getting log message', () => { + const exception = new ElementTypeNotSupportedLoggableException('notSupportedType'); + + it('should return log message', () => { + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: CommonCartridgeErrorEnum.ELEMENT_TYPE_NOT_SUPPORTED, + stack: exception.stack, + data: { + type: 'notSupportedType', + message: 'Common Cartridge element type notSupportedType is not supported', + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.ts b/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.ts new file mode 100644 index 00000000000..19dd0ff941d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/element-type-not-supported.loggable-exception.ts @@ -0,0 +1,24 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +export class ElementTypeNotSupportedLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly type: string) { + super({ + type: CommonCartridgeErrorEnum.ELEMENT_TYPE_NOT_SUPPORTED, + }); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: CommonCartridgeErrorEnum.ELEMENT_TYPE_NOT_SUPPORTED, + stack: this.stack, + data: { + type: this.type, + message: `Common Cartridge element type ${this.type} is not supported`, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/errors/error.enums.ts b/apps/server/src/modules/common-cartridge/export/errors/error.enums.ts new file mode 100644 index 00000000000..0f5081717c9 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/error.enums.ts @@ -0,0 +1,7 @@ +export enum CommonCartridgeErrorEnum { + VERSION_NOT_SUPPORTED = 'VERSION_NOT_SUPPORTED', + RESOURCE_TYPE_NOT_SUPPORTED = 'RESOURCE_TYPE_NOT_SUPPORTED', + ELEMENT_TYPE_NOT_SUPPORTED = 'ELEMENT_TYPE_NOT_SUPPORTED', + INTENDED_USE_NOT_SUPPORTED = 'INTENDED_USE_NOT_SUPPORTED', + MISSING_METADATA = 'MISSING_METADATA', +} diff --git a/apps/server/src/modules/common-cartridge/export/errors/index.ts b/apps/server/src/modules/common-cartridge/export/errors/index.ts new file mode 100644 index 00000000000..a3f364f2793 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/index.ts @@ -0,0 +1,5 @@ +export { ElementTypeNotSupportedLoggableException } from './element-type-not-supported.loggable-exception'; +export { IntendedUseNotSupportedLoggableException } from './intended-use-not-supported.loggable-exception'; +export { MissingMetadataLoggableException } from './missing-metadata.loggable-exception'; +export { ResourceTypeNotSupportedLoggableException } from './resource-type-not-supported.loggable-exception'; +export { VersionNotSupportedLoggableException } from './version-not-supported.loggable-exception'; diff --git a/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.spec.ts b/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.spec.ts new file mode 100644 index 00000000000..3bc2353cdae --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.spec.ts @@ -0,0 +1,23 @@ +import { CommonCartridgeErrorEnum } from './error.enums'; +import { IntendedUseNotSupportedLoggableException } from './intended-use-not-supported.loggable-exception'; + +describe('IntendedUseNotSupportedLoggableException', () => { + describe('getLogMessage', () => { + describe('when getting log message', () => { + const exception = new IntendedUseNotSupportedLoggableException('notSupportedIntendedUse'); + + it('should return log message', () => { + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: CommonCartridgeErrorEnum.INTENDED_USE_NOT_SUPPORTED, + stack: exception.stack, + data: { + intendedUse: 'notSupportedIntendedUse', + message: 'Common Cartridge intended use notSupportedIntendedUse is not supported', + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.ts b/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.ts new file mode 100644 index 00000000000..5259bd517f1 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/intended-use-not-supported.loggable-exception.ts @@ -0,0 +1,24 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +export class IntendedUseNotSupportedLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly intendedUse: string) { + super({ + type: CommonCartridgeErrorEnum.INTENDED_USE_NOT_SUPPORTED, + }); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: CommonCartridgeErrorEnum.INTENDED_USE_NOT_SUPPORTED, + stack: this.stack, + data: { + intendedUse: this.intendedUse, + message: `Common Cartridge intended use ${this.intendedUse} is not supported`, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.spec.ts b/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.spec.ts new file mode 100644 index 00000000000..3d0b32d87d9 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.spec.ts @@ -0,0 +1,22 @@ +import { CommonCartridgeErrorEnum } from './error.enums'; +import { MissingMetadataLoggableException } from './missing-metadata.loggable-exception'; + +describe('MissingMetadataLoggableException', () => { + describe('getLogMessage', () => { + describe('when getting log message', () => { + const exception = new MissingMetadataLoggableException(); + + it('should return log message', () => { + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: CommonCartridgeErrorEnum.MISSING_METADATA, + stack: exception.stack, + data: { + message: 'Metadata is required', + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.ts b/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.ts new file mode 100644 index 00000000000..9ee3d6bcff0 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/missing-metadata.loggable-exception.ts @@ -0,0 +1,23 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +export class MissingMetadataLoggableException extends InternalServerErrorException implements Loggable { + constructor() { + super({ + type: CommonCartridgeErrorEnum.MISSING_METADATA, + }); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: CommonCartridgeErrorEnum.MISSING_METADATA, + stack: this.stack, + data: { + message: 'Metadata is required', + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.spec.ts b/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.spec.ts new file mode 100644 index 00000000000..b51cb43cd0b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.spec.ts @@ -0,0 +1,23 @@ +import { CommonCartridgeErrorEnum } from './error.enums'; +import { ResourceTypeNotSupportedLoggableException } from './resource-type-not-supported.loggable-exception'; + +describe('ResourceTypeNotSupportedLoggableException', () => { + describe('getLogMessage', () => { + describe('when getting log message', () => { + const exception = new ResourceTypeNotSupportedLoggableException('notSupportedType'); + + it('should return log message', () => { + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: CommonCartridgeErrorEnum.RESOURCE_TYPE_NOT_SUPPORTED, + stack: exception.stack, + data: { + type: 'notSupportedType', + message: 'Common Cartridge resource type notSupportedType is not supported', + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.ts b/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.ts new file mode 100644 index 00000000000..4d9d9d8a8e1 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/resource-type-not-supported.loggable-exception.ts @@ -0,0 +1,24 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +export class ResourceTypeNotSupportedLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly type: string) { + super({ + type: CommonCartridgeErrorEnum.RESOURCE_TYPE_NOT_SUPPORTED, + }); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: CommonCartridgeErrorEnum.RESOURCE_TYPE_NOT_SUPPORTED, + stack: this.stack, + data: { + type: this.type, + message: `Common Cartridge resource type ${this.type} is not supported`, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.spec.ts b/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.spec.ts new file mode 100644 index 00000000000..5ec29c55fdb --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.spec.ts @@ -0,0 +1,23 @@ +import { CommonCartridgeErrorEnum } from './error.enums'; +import { VersionNotSupportedLoggableException } from './version-not-supported.loggable-exception'; + +describe('VersionNotSupportedLoggableException', () => { + describe('getLogMessage', () => { + describe('when getting log message', () => { + const exception = new VersionNotSupportedLoggableException('notSupportedVersion'); + + it('should return log message', () => { + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: CommonCartridgeErrorEnum.VERSION_NOT_SUPPORTED, + stack: exception.stack, + data: { + version: 'notSupportedVersion', + message: 'Common Cartridge version notSupportedVersion is not supported', + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.ts b/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.ts new file mode 100644 index 00000000000..bb22519217e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/errors/version-not-supported.loggable-exception.ts @@ -0,0 +1,24 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { CommonCartridgeErrorEnum } from './error.enums'; + +export class VersionNotSupportedLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly version: string) { + super({ + type: CommonCartridgeErrorEnum.VERSION_NOT_SUPPORTED, + }); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: CommonCartridgeErrorEnum.VERSION_NOT_SUPPORTED, + stack: this.stack, + data: { + version: this.version, + message: `Common Cartridge version ${this.version} is not supported`, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-base.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-base.interface.ts new file mode 100644 index 00000000000..0b768edfbc7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-base.interface.ts @@ -0,0 +1,30 @@ +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { VersionNotSupportedLoggableException } from '../errors'; + +type CommonCartridgeBaseProps = { + version: CommonCartridgeVersion; + identifier?: string; + title?: string; +}; + +export abstract class CommonCartridgeBase { + protected constructor(public readonly baseProps: CommonCartridgeBaseProps) { + this.checkVersion(baseProps.version); + } + + public get identifier(): string | undefined { + return this.baseProps.identifier; + } + + public get title(): string | undefined { + return this.baseProps.title; + } + + abstract getSupportedVersion(): CommonCartridgeVersion; + + private checkVersion(target: CommonCartridgeVersion): void { + if (this.getSupportedVersion() !== target) { + throw new VersionNotSupportedLoggableException(target); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts index 869aa6f576f..dae8a1ecf78 100644 --- a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts +++ b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts @@ -1,43 +1,14 @@ -import { CommonCartridgeVersion } from '../common-cartridge.enums'; -import { createVersionNotSupportedError } from '../utils'; - -type CommonCartridgeElementProps = { - version: CommonCartridgeVersion; - identifier?: string; - title?: string; -}; +import { CommonCartridgeElementType } from '../common-cartridge.enums'; +import { CommonCartridgeBase } from './common-cartridge-base.interface'; +import { XmlObject } from './xml-object.interface'; /** * Every element which should be listed in the Common Cartridge manifest must implement this interface. */ -export abstract class CommonCartridgeElement { - protected constructor(private readonly baseProps: CommonCartridgeElementProps) { - this.checkVersion(baseProps.version); - } - - public get identifier(): string | undefined { - return this.baseProps.identifier; - } - - public get title(): string | undefined { - return this.baseProps.title; - } - - /** - * Every element must know which versions it supports. - * @returns The supported versions for this element. - */ - abstract getSupportedVersion(): CommonCartridgeVersion; - +export abstract class CommonCartridgeElement extends CommonCartridgeBase { /** * This method is used to build the imsmanifest.xml file. * @returns The XML object representation for the imsmanifest.xml file. */ - abstract getManifestXmlObject(): Record; - - private checkVersion(target: CommonCartridgeVersion): void { - if (this.getSupportedVersion() !== target) { - throw createVersionNotSupportedError(target); - } - } + abstract getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject; } diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts index dfa3adbc8b4..b75b3524d06 100644 --- a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts +++ b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts @@ -4,12 +4,6 @@ import { CommonCartridgeElement } from './common-cartridge-element.interface'; * Every resource which should be added to the Common Cartridge archive must implement this interface. */ export abstract class CommonCartridgeResource extends CommonCartridgeElement { - /** - * In later Common Cartridge versions, resources can be inlined in the imsmanifest.xml file. - * @returns true if the resource can be inlined, otherwise false. - */ - abstract canInline(): boolean; - /** * This method is used to determine the path of the resource in the Common Cartridge archive. * @returns The path of the resource in the Common Cartridge archive. diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/index.ts b/apps/server/src/modules/common-cartridge/export/interfaces/index.ts index aaefb39018c..1f5d4394c4d 100644 --- a/apps/server/src/modules/common-cartridge/export/interfaces/index.ts +++ b/apps/server/src/modules/common-cartridge/export/interfaces/index.ts @@ -1,2 +1,4 @@ +export { CommonCartridgeBase } from './common-cartridge-base.interface'; export { CommonCartridgeElement } from './common-cartridge-element.interface'; export { CommonCartridgeResource } from './common-cartridge-resource.interface'; +export { XmlObject } from './xml-object.interface'; diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/xml-object.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/xml-object.interface.ts new file mode 100644 index 00000000000..583e52d208e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/interfaces/xml-object.interface.ts @@ -0,0 +1 @@ +export interface XmlObject extends Record {} diff --git a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts index 9bd274d4810..d1d6ab6ae76 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeWebContentResourcePropsV110, createCommonCartridgeWebContentResourcePropsV130, } from '../../testing/common-cartridge-resource-props.factory'; import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { VersionNotSupportedLoggableException } from '../errors'; import { CommonCartridgeResourceFactory } from './common-cartridge-resource-factory'; import { CommonCartridgeWebContentResourcePropsV110, CommonCartridgeWebContentResourceV110 } from './v1.1.0'; import { CommonCartridgeWebContentResourceV130 } from './v1.3.0'; @@ -35,14 +35,14 @@ describe('CommonCartridgeResourceVersion', () => { CommonCartridgeVersion.V_1_4_0, ]; - it('should throw InternalServerErrorException', () => { + it('should throw VersionNotSupportedLoggableException', () => { notSupportedVersions.forEach((version) => { expect(() => CommonCartridgeResourceFactory.createResource({ version, type: CommonCartridgeResourceType.WEB_CONTENT, } as CommonCartridgeWebContentResourcePropsV110) - ).toThrow(InternalServerErrorException); + ).toThrow(VersionNotSupportedLoggableException); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts index 673672c129e..1ef3cc1428d 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts @@ -1,6 +1,7 @@ import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { VersionNotSupportedLoggableException } from '../errors'; import { CommonCartridgeResource } from '../interfaces'; -import { OmitVersionAndFolder, createVersionNotSupportedError } from '../utils'; +import { OmitVersionAndFolder } from '../utils'; import { CommonCartridgeManifestResourcePropsV110, CommonCartridgeResourceFactoryV110, @@ -20,7 +21,7 @@ export type CommonCartridgeResourceProps = | OmitVersionAndFolder | OmitVersionAndFolder; -type CommonCartridgeResourcePropsInternal = +export type CommonCartridgeResourcePropsInternal = | CommonCartridgeManifestResourcePropsV110 | CommonCartridgeWebContentResourcePropsV110 | CommonCartridgeWebLinkResourcePropsV110 @@ -38,7 +39,7 @@ export class CommonCartridgeResourceFactory { case CommonCartridgeVersion.V_1_3_0: return CommonCartridgeResourceFactoryV130.createResource(props); default: - throw createVersionNotSupportedError(version); + throw new VersionNotSupportedLoggableException(version); } } } diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts index aa63bb9237f..129738f323f 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts @@ -8,27 +8,14 @@ import { CommonCartridgeVersion, } from '../../common-cartridge.enums'; import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import * as utils from '../../utils'; import { CommonCartridgeResourceFactory } from '../common-cartridge-resource-factory'; import { CommonCartridgeManifestResourceV110 } from './common-cartridge-manifest-resource'; describe('CommonCartridgeManifestResourceV110', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.1.0', () => { - const setup = () => { - const props = createCommonCartridgeManifestResourcePropsV110(); - const sut = new CommonCartridgeManifestResourceV110(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); + beforeEach(() => { + jest.clearAllMocks(); }); describe('getFilePath', () => { @@ -100,6 +87,11 @@ describe('CommonCartridgeManifestResourceV110', () => { resources: [resource1, resource2], }); + // we need this, otherwise the identifier will be random and we have to updated + // the manifest.xml file which we will compare with the expected content in the test + const mockValues = ['o1', 'o2']; + jest.spyOn(utils, 'createIdentifier').mockImplementation(() => mockValues.shift() ?? ''); + return { sut }; }; @@ -144,4 +136,46 @@ describe('CommonCartridgeManifestResourceV110', () => { }); }); }); + + describe('getManifestXmlObject', () => { + describe('when creating manifest xml object', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV110(); + const sut = new CommonCartridgeManifestResourceV110(props); + + return { sut }; + }; + + it('should return manifest xml object', () => { + const { sut } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.MANIFEST); + + expect(result).toStrictEqual({ + manifest: { + $: expect.any(Object) as unknown, + metadata: expect.any(Object) as unknown, + organizations: expect.any(Object) as unknown, + resources: expect.any(Object) as unknown, + }, + }); + }); + }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeManifestResourcePropsV110(); + const sut = new CommonCartridgeManifestResourceV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); + }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts index ae578c34e4a..1f597492d03 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts @@ -4,7 +4,8 @@ import { CommonCartridgeVersion, } from '../../common-cartridge.enums'; import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, CommonCartridgeResource, XmlObject } from '../../interfaces'; import { buildXmlString } from '../../utils'; export type CommonCartridgeManifestResourcePropsV110 = { @@ -21,8 +22,17 @@ export class CommonCartridgeManifestResourceV110 extends CommonCartridgeResource super(props); } - public canInline(): boolean { - return false; + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.MANIFEST: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } } public getFilePath(): string { @@ -30,14 +40,10 @@ export class CommonCartridgeManifestResourceV110 extends CommonCartridgeResource } public getFileContent(): string { - return buildXmlString(this.getManifestXmlObject()); - } - - public getSupportedVersion(): CommonCartridgeVersion { - return CommonCartridgeVersion.V_1_1_0; + return buildXmlString(this.getManifestXmlObjectInternal()); } - public getManifestXmlObject(): Record { + private getManifestXmlObjectInternal(): XmlObject { return { manifest: { $: { @@ -51,17 +57,17 @@ export class CommonCartridgeManifestResourceV110 extends CommonCartridgeResource 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest https://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd ' + 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource https://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd', }, - metadata: this.props.metadata.getManifestXmlObject(), + metadata: this.props.metadata.getManifestXmlObject(CommonCartridgeElementType.METADATA), organizations: CommonCartridgeElementFactory.createElement({ type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, version: this.props.version, items: this.props.organizations, - }).getManifestXmlObject(), + }).getManifestXmlObject(CommonCartridgeElementType.ORGANIZATIONS_WRAPPER), ...CommonCartridgeElementFactory.createElement({ type: CommonCartridgeElementType.RESOURCES_WRAPPER, version: this.props.version, items: this.props.resources, - }).getManifestXmlObject(), + }).getManifestXmlObject(CommonCartridgeElementType.RESOURCES_WRAPPER), }, }; } diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts index fd93be44d2d..3e214a828ee 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeManifestResourcePropsV110, createCommonCartridgeWebContentResourcePropsV110, createCommonCartridgeWeblinkResourcePropsV110, } from '../../../testing/common-cartridge-resource-props.factory'; +import { ResourceTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeManifestResourceV110 } from './common-cartridge-manifest-resource'; import { CommonCartridgeResourceFactoryV110 } from './common-cartridge-resource-factory'; import { CommonCartridgeWebContentResourceV110 } from './common-cartridge-web-content-resource'; @@ -41,10 +41,10 @@ describe('CommonCartridgeResourceFactoryV110', () => { }); describe('when resource type is not supported', () => { - it('should throw error', () => { + it('should throw ResourceTypeNotSupportedLoggableException', () => { expect(() => CommonCartridgeResourceFactoryV110.createResource({} as CommonCartridgeWebLinkResourcePropsV110) - ).toThrow(InternalServerErrorException); + ).toThrow(ResourceTypeNotSupportedLoggableException); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts index 4e13ec77587..ffb94273dce 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts @@ -1,6 +1,6 @@ import { CommonCartridgeResourceType } from '../../common-cartridge.enums'; +import { ResourceTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResource } from '../../interfaces'; -import { createResourceTypeNotSupportedError } from '../../utils'; import { CommonCartridgeManifestResourcePropsV110, CommonCartridgeManifestResourceV110, @@ -31,7 +31,7 @@ export class CommonCartridgeResourceFactoryV110 { case CommonCartridgeResourceType.WEB_LINK: return new CommonCartridgeWebLinkResourceV110(props); default: - throw createResourceTypeNotSupportedError(type); + throw new ResourceTypeNotSupportedLoggableException(type); } } } diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts index 169986b451e..40ae8a4f2e1 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts @@ -1,28 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeWebContentResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeWebContentResourceV110 } from './common-cartridge-web-content-resource'; describe('CommonCartridgeWebContentResourceV110', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.1.0', () => { - const setup = () => { - const props = createCommonCartridgeWebContentResourcePropsV110(); - const sut = new CommonCartridgeWebContentResourceV110(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); - }); - describe('getFilePath', () => { describe('when using Common Cartridge version 1.1.0', () => { const setup = () => { @@ -85,14 +66,37 @@ describe('CommonCartridgeWebContentResourceV110', () => { it('should throw error', () => { expect(() => new CommonCartridgeWebContentResourceV110(notSupportedProps)).toThrow( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.1.0', () => { + describe('when creating organization xml object', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut, props }; + }; + + it('should return organization manifest fragment', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); + + expect(result).toEqual({ + $: { + identifier: expect.any(String), + identifierref: props.identifier, + }, + title: props.title, + }); + }); + }); + + describe('when creating resource xml object', () => { const setup = () => { const props = createCommonCartridgeWebContentResourcePropsV110(); const sut = new CommonCartridgeWebContentResourceV110(props); @@ -100,10 +104,10 @@ describe('CommonCartridgeWebContentResourceV110', () => { return { sut, props }; }; - it('should return the correct XML object', () => { + it('should return resource manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCE); expect(result).toEqual({ $: { @@ -119,5 +123,21 @@ describe('CommonCartridgeWebContentResourceV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts index f684709e246..e097a707960 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts @@ -1,10 +1,13 @@ import { + CommonCartridgeElementType, CommonCartridgeIntendedUseType, CommonCartridgeResourceType, CommonCartridgeVersion, } from '../../common-cartridge.enums'; -import { CommonCartridgeResource } from '../../interfaces'; -import { checkIntendedUse } from '../../utils'; +import { CommonCartridgeGuard } from '../../common-cartridge.guard'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeResource, XmlObject } from '../../interfaces'; +import { createIdentifier } from '../../utils'; export type CommonCartridgeWebContentResourcePropsV110 = { type: CommonCartridgeResourceType.WEB_CONTENT; @@ -25,11 +28,25 @@ export class CommonCartridgeWebContentResourceV110 extends CommonCartridgeResour constructor(private readonly props: CommonCartridgeWebContentResourcePropsV110) { super(props); - checkIntendedUse(props.intendedUse, CommonCartridgeWebContentResourceV110.SUPPORTED_INTENDED_USES); + CommonCartridgeGuard.checkIntendedUse( + props.intendedUse, + CommonCartridgeWebContentResourceV110.SUPPORTED_INTENDED_USES + ); } - public canInline(): boolean { - return false; + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.RESOURCE: + return this.getManifestResourceXmlObject(); + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestOrganizationXmlObject(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } } public getFilePath(): string { @@ -40,11 +57,17 @@ export class CommonCartridgeWebContentResourceV110 extends CommonCartridgeResour return this.props.html; } - public getSupportedVersion(): CommonCartridgeVersion { - return CommonCartridgeVersion.V_1_1_0; + private getManifestOrganizationXmlObject(): XmlObject { + return { + $: { + identifier: createIdentifier(), + identifierref: this.props.identifier, + }, + title: this.props.title, + }; } - public getManifestXmlObject(): Record { + private getManifestResourceXmlObject(): XmlObject { return { $: { identifier: this.props.identifier, diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts index 79881d285f4..b697a51f871 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts @@ -1,29 +1,11 @@ import { InternalServerErrorException } from '@nestjs/common'; import { readFile } from 'fs/promises'; import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeWebLinkResourceV110 } from './common-cartridge-web-link-resource'; describe('CommonCartridgeWebLinkResourceV110', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.3.0', () => { - const setup = () => { - const props = createCommonCartridgeWeblinkResourcePropsV110(); - const sut = new CommonCartridgeWebLinkResourceV110(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); - }); - describe('getFilePath', () => { describe('when using Common Cartridge version 1.1.0', () => { const setup = () => { @@ -99,7 +81,7 @@ describe('CommonCartridgeWebLinkResourceV110', () => { }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.1.0', () => { + describe('when creating organization xml object', () => { const setup = () => { const props = createCommonCartridgeWeblinkResourcePropsV110(); const sut = new CommonCartridgeWebLinkResourceV110(props); @@ -107,10 +89,33 @@ describe('CommonCartridgeWebLinkResourceV110', () => { return { sut, props }; }; - it('should return the manifest XML object', () => { + it('should return resource manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); + + expect(result).toEqual({ + $: { + identifier: expect.any(String), + identifierref: props.identifier, + }, + title: props.title, + }); + }); + }); + + describe('when creating resource xml object', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut, props }; + }; + + it('should return resource manifest fragment', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCE); expect(result).toEqual({ $: { @@ -125,5 +130,21 @@ describe('CommonCartridgeWebLinkResourceV110', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts index 4da6c641215..b6fd594de7d 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts @@ -1,6 +1,11 @@ -import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeResource } from '../../interfaces'; -import { buildXmlString } from '../../utils'; +import { + CommonCartridgeElementType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeResource, XmlObject } from '../../interfaces'; +import { buildXmlString, createIdentifier } from '../../utils'; export type CommonCartridgeWebLinkResourcePropsV110 = { type: CommonCartridgeResourceType.WEB_LINK; @@ -18,8 +23,19 @@ export class CommonCartridgeWebLinkResourceV110 extends CommonCartridgeResource super(props); } - public canInline(): boolean { - return false; + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.RESOURCE: + return this.getManifestResourceXmlObject(); + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestOrganizationXmlObject(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } } public getFilePath(): string { @@ -47,14 +63,20 @@ export class CommonCartridgeWebLinkResourceV110 extends CommonCartridgeResource }); } - public getSupportedVersion(): CommonCartridgeVersion { - return CommonCartridgeVersion.V_1_1_0; + private getManifestOrganizationXmlObject(): XmlObject { + return { + $: { + identifier: createIdentifier(), + identifierref: this.identifier, + }, + title: this.title, + }; } - public getManifestXmlObject(): Record { + private getManifestResourceXmlObject(): XmlObject { return { $: { - identifier: this.props.identifier, + identifier: this.identifier, type: 'imswl_xmlv1p1', }, file: { diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts index f49caa016c9..eb0bd8e5696 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts @@ -9,27 +9,14 @@ import { } from '../../common-cartridge.enums'; import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; import { CommonCartridgeElementFactoryV130 } from '../../elements/v1.3.0'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import * as utils from '../../utils'; import { CommonCartridgeResourceFactory } from '../common-cartridge-resource-factory'; import { CommonCartridgeManifestResourceV130 } from './common-cartridge-manifest-resource'; describe('CommonCartridgeManifestResourceV130', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.3.0', () => { - const setup = () => { - const props = createCommonCartridgeManifestResourcePropsV130(); - const sut = new CommonCartridgeManifestResourceV130(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); + beforeEach(() => { + jest.clearAllMocks(); }); describe('getFilePath', () => { @@ -101,6 +88,11 @@ describe('CommonCartridgeManifestResourceV130', () => { resources: [resource1, resource2], }); + // we need this, otherwise the identifier will be random and we have to updated + // the manifest.xml file which we will compare with the expected content in the test + const mockValues = ['o1', 'o2']; + jest.spyOn(utils, 'createIdentifier').mockImplementation(() => mockValues.shift() ?? ''); + return { sut }; }; @@ -145,4 +137,46 @@ describe('CommonCartridgeManifestResourceV130', () => { }); }); }); + + describe('getManifestXmlObject', () => { + describe('when creating manifest xml object', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV130(); + const sut = new CommonCartridgeManifestResourceV130(props); + + return { sut }; + }; + + it('should return manifest xml object', () => { + const { sut } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.MANIFEST); + + expect(result).toStrictEqual({ + manifest: { + $: expect.any(Object) as unknown, + metadata: expect.any(Object) as unknown, + organizations: expect.any(Object) as unknown, + resources: expect.any(Object) as unknown, + }, + }); + }); + }); + + describe('when element type is not supported', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeManifestResourcePropsV130(); + const sut = new CommonCartridgeManifestResourceV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); + }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts index 3da27cb30b8..63f543baf07 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts @@ -7,7 +7,8 @@ import { CommonCartridgeOrganizationsWrapperElementV130, CommonCartridgeResourcesWrapperElementV130, } from '../../elements/v1.3.0'; -import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeElement, CommonCartridgeResource, XmlObject } from '../../interfaces'; import { buildXmlString } from '../../utils'; export type CommonCartridgeManifestResourcePropsV130 = { @@ -24,23 +25,28 @@ export class CommonCartridgeManifestResourceV130 extends CommonCartridgeResource super(props); } - public canInline(): boolean { - return false; - } - public getFilePath(): string { return 'imsmanifest.xml'; } public getFileContent(): string { - return buildXmlString(this.getManifestXmlObject()); + return buildXmlString(this.getManifestXmlObject(CommonCartridgeElementType.MANIFEST)); } public getSupportedVersion(): CommonCartridgeVersion { return CommonCartridgeVersion.V_1_3_0; } - public getManifestXmlObject(): Record { + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.MANIFEST: + return this.getManifestXmlObjectInternal(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } + } + + public getManifestXmlObjectInternal(): XmlObject { return { manifest: { $: { @@ -54,17 +60,17 @@ export class CommonCartridgeManifestResourceV130 extends CommonCartridgeResource 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest https://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd ' + 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource https://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd', }, - metadata: this.props.metadata.getManifestXmlObject(), + metadata: this.props.metadata.getManifestXmlObject(CommonCartridgeElementType.METADATA), organizations: new CommonCartridgeOrganizationsWrapperElementV130({ type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, version: this.props.version, items: this.props.organizations, - }).getManifestXmlObject(), + }).getManifestXmlObject(CommonCartridgeElementType.ORGANIZATIONS_WRAPPER), ...new CommonCartridgeResourcesWrapperElementV130({ type: CommonCartridgeElementType.RESOURCES_WRAPPER, version: this.props.version, items: this.props.resources, - }).getManifestXmlObject(), + }).getManifestXmlObject(CommonCartridgeElementType.RESOURCES_WRAPPER), }, }; } diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts index ad630ea25d8..799e435ee00 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts @@ -1,9 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeManifestResourcePropsV130, createCommonCartridgeWebContentResourcePropsV130, createCommonCartridgeWeblinkResourcePropsV130, } from '../../../testing/common-cartridge-resource-props.factory'; +import { ResourceTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeManifestResourceV130 } from './common-cartridge-manifest-resource'; import { CommonCartridgeResourceFactoryV130 } from './common-cartridge-resource-factory'; import { CommonCartridgeWebContentResourceV130 } from './common-cartridge-web-content-resource'; @@ -41,10 +41,10 @@ describe('CommonCartridgeResourceFactoryV130', () => { }); describe('when resource type is not supported', () => { - it('should throw error', () => { + it('should throw ResourceTypeNotSupportedLoggableException', () => { expect(() => CommonCartridgeResourceFactoryV130.createResource({} as CommonCartridgeWebLinkResourcePropsV130) - ).toThrow(InternalServerErrorException); + ).toThrow(ResourceTypeNotSupportedLoggableException); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts index 9be5fad11c1..a842c1a4a98 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts @@ -1,6 +1,6 @@ import { CommonCartridgeResourceType } from '../../common-cartridge.enums'; +import { ResourceTypeNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeResource } from '../../interfaces'; -import { createResourceTypeNotSupportedError } from '../../utils'; import { CommonCartridgeManifestResourcePropsV130, CommonCartridgeManifestResourceV130, @@ -31,7 +31,7 @@ export class CommonCartridgeResourceFactoryV130 { case CommonCartridgeResourceType.WEB_LINK: return new CommonCartridgeWebLinkResourceV130(props); default: - throw createResourceTypeNotSupportedError(type); + throw new ResourceTypeNotSupportedLoggableException(type); } } } diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts index e1b3334fd7d..44ab6b65002 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts @@ -1,28 +1,9 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { createCommonCartridgeWebContentResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeWebContentResourceV130 } from './common-cartridge-web-content-resource'; describe('CommonCartridgeWebContentResourceV130', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.3.0', () => { - const setup = () => { - const props = createCommonCartridgeWebContentResourcePropsV130(); - const sut = new CommonCartridgeWebContentResourceV130(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); - }); - describe('getFilePath', () => { describe('when using Common Cartridge version 1.3.0', () => { const setup = () => { @@ -85,14 +66,37 @@ describe('CommonCartridgeWebContentResourceV130', () => { it('should throw error', () => { expect(() => new CommonCartridgeWebContentResourceV130(notSupportedProps)).toThrow( - InternalServerErrorException + VersionNotSupportedLoggableException ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.3.0', () => { + describe('when creating organization xml object', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut, props }; + }; + + it('should return organization manifest fragment', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); + + expect(result).toEqual({ + $: { + identifier: expect.any(String), + identifierref: props.identifier, + }, + title: props.title, + }); + }); + }); + + describe('when creating resource xml object', () => { const setup = () => { const props = createCommonCartridgeWebContentResourcePropsV130(); const sut = new CommonCartridgeWebContentResourceV130(props); @@ -100,10 +104,10 @@ describe('CommonCartridgeWebContentResourceV130', () => { return { sut, props }; }; - it('should return the manifest XML object', () => { + it('should return resource manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCE); expect(result).toEqual({ $: { @@ -119,5 +123,21 @@ describe('CommonCartridgeWebContentResourceV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts index eb168087a52..21aa09a0a2d 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts @@ -1,10 +1,13 @@ import { + CommonCartridgeElementType, CommonCartridgeIntendedUseType, CommonCartridgeResourceType, CommonCartridgeVersion, } from '../../common-cartridge.enums'; -import { CommonCartridgeResource } from '../../interfaces'; -import { checkIntendedUse } from '../../utils'; +import { CommonCartridgeGuard } from '../../common-cartridge.guard'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeResource, XmlObject } from '../../interfaces'; +import { createIdentifier } from '../../utils'; export type CommonCartridgeWebContentResourcePropsV130 = { type: CommonCartridgeResourceType.WEB_CONTENT; @@ -26,11 +29,25 @@ export class CommonCartridgeWebContentResourceV130 extends CommonCartridgeResour constructor(private readonly props: CommonCartridgeWebContentResourcePropsV130) { super(props); - checkIntendedUse(props.intendedUse, CommonCartridgeWebContentResourceV130.SUPPORTED_INTENDED_USES); + CommonCartridgeGuard.checkIntendedUse( + props.intendedUse, + CommonCartridgeWebContentResourceV130.SUPPORTED_INTENDED_USES + ); } - public canInline(): boolean { - return false; + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.RESOURCE: + return this.getManifestResourceXmlObject(); + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestOrganizationXmlObject(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } } public getFilePath(): string { @@ -41,11 +58,17 @@ export class CommonCartridgeWebContentResourceV130 extends CommonCartridgeResour return this.props.html; } - public getSupportedVersion(): CommonCartridgeVersion { - return CommonCartridgeVersion.V_1_3_0; + private getManifestOrganizationXmlObject(): XmlObject { + return { + $: { + identifier: createIdentifier(), + identifierref: this.props.identifier, + }, + title: this.props.title, + }; } - public getManifestXmlObject(): Record { + private getManifestResourceXmlObject(): XmlObject { return { $: { identifier: this.props.identifier, diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts index d6aee5e394f..83985e8f717 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts @@ -1,29 +1,10 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; -import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException, VersionNotSupportedLoggableException } from '../../errors'; import { CommonCartridgeWebLinkResourceV130 } from './common-cartridge-web-link-resource'; describe('CommonCartridgeWebLinkResourceV130', () => { - describe('canInline', () => { - describe('when using Common Cartridge version 1.3.0', () => { - const setup = () => { - const props = createCommonCartridgeWeblinkResourcePropsV130(); - const sut = new CommonCartridgeWebLinkResourceV130(props); - - return { sut }; - }; - - it('should return false', () => { - const { sut } = setup(); - - const result = sut.canInline(); - - expect(result).toBe(false); - }); - }); - }); - describe('getFilePath', () => { describe('when using Common Cartridge version 1.3.0', () => { const setup = () => { @@ -94,13 +75,38 @@ describe('CommonCartridgeWebLinkResourceV130', () => { notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; it('should throw error', () => { - expect(() => new CommonCartridgeWebLinkResourceV130(notSupportedProps)).toThrow(InternalServerErrorException); + expect(() => new CommonCartridgeWebLinkResourceV130(notSupportedProps)).toThrow( + VersionNotSupportedLoggableException + ); }); }); }); describe('getManifestXmlObject', () => { - describe('when using Common Cartridge version 1.3.0', () => { + describe('when creating organization xml object', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut, props }; + }; + + it('should return organization manifest fragment', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(CommonCartridgeElementType.ORGANIZATION); + + expect(result).toEqual({ + $: { + identifier: expect.any(String), + identifierref: props.identifier, + }, + title: props.title, + }); + }); + }); + + describe('when creating resource xml object', () => { const setup = () => { const props = createCommonCartridgeWeblinkResourcePropsV130(); const sut = new CommonCartridgeWebLinkResourceV130(props); @@ -108,10 +114,10 @@ describe('CommonCartridgeWebLinkResourceV130', () => { return { sut, props }; }; - it('should return the manifest XML object', () => { + it('should return resource manifest fragment', () => { const { sut, props } = setup(); - const result = sut.getManifestXmlObject(); + const result = sut.getManifestXmlObject(CommonCartridgeElementType.RESOURCE); expect(result).toEqual({ $: { @@ -127,4 +133,20 @@ describe('CommonCartridgeWebLinkResourceV130', () => { }); }); }); + + describe('when using unsupported element type', () => { + const setup = () => { + const unknownElementType = 'unknown' as CommonCartridgeElementType; + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut, unknownElementType }; + }; + + it('should throw error', () => { + const { sut, unknownElementType } = setup(); + + expect(() => sut.getManifestXmlObject(unknownElementType)).toThrow(ElementTypeNotSupportedLoggableException); + }); + }); }); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts index 1cfd8a3df5b..eb5d66ad8eb 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts @@ -1,6 +1,11 @@ -import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../../common-cartridge.enums'; -import { CommonCartridgeResource } from '../../interfaces'; -import { buildXmlString } from '../../utils'; +import { + CommonCartridgeElementType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { ElementTypeNotSupportedLoggableException } from '../../errors'; +import { CommonCartridgeResource, XmlObject } from '../../interfaces'; +import { buildXmlString, createIdentifier } from '../../utils'; export type CommonCartridgeWebLinkResourcePropsV130 = { type: CommonCartridgeResourceType.WEB_LINK; @@ -18,8 +23,19 @@ export class CommonCartridgeWebLinkResourceV130 extends CommonCartridgeResource super(props); } - public canInline(): boolean { - return false; + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(elementType: CommonCartridgeElementType): XmlObject { + switch (elementType) { + case CommonCartridgeElementType.RESOURCE: + return this.getManifestResourceXmlObject(); + case CommonCartridgeElementType.ORGANIZATION: + return this.getManifestOrganizationXmlObject(); + default: + throw new ElementTypeNotSupportedLoggableException(elementType); + } } public getFilePath(): string { @@ -47,11 +63,17 @@ export class CommonCartridgeWebLinkResourceV130 extends CommonCartridgeResource }); } - public getSupportedVersion(): CommonCartridgeVersion { - return CommonCartridgeVersion.V_1_3_0; + private getManifestOrganizationXmlObject(): XmlObject { + return { + $: { + identifier: createIdentifier(), + identifierref: this.props.identifier, + }, + title: this.props.title, + }; } - public getManifestXmlObject(): Record { + private getManifestResourceXmlObject(): XmlObject { return { $: { identifier: this.props.identifier, diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts index dca0baca323..90e0b5121ea 100644 --- a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts @@ -1,7 +1,7 @@ export { CommonCartridgeManifestResourcePropsV130 } from './common-cartridge-manifest-resource'; export { CommonCartridgeResourceFactoryV130 } from './common-cartridge-resource-factory'; export { - CommonCartridgeWebContentResourcePropsV130, CommonCartridgeWebContentResourceV130, + CommonCartridgeWebContentResourcePropsV130, } from './common-cartridge-web-content-resource'; export { CommonCartridgeWebLinkResourcePropsV130 } from './common-cartridge-web-link-resource'; diff --git a/apps/server/src/modules/common-cartridge/export/utils.spec.ts b/apps/server/src/modules/common-cartridge/export/utils.spec.ts index 3dae9ffa1c8..2a5b5b7de60 100644 --- a/apps/server/src/modules/common-cartridge/export/utils.spec.ts +++ b/apps/server/src/modules/common-cartridge/export/utils.spec.ts @@ -1,14 +1,5 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { ObjectId } from 'bson'; -import { CommonCartridgeVersion } from './common-cartridge.enums'; -import { - buildXmlString, - checkIntendedUse, - createElementTypeNotSupportedError, - createIdentifier, - createResourceTypeNotSupportedError, - createVersionNotSupportedError, -} from './utils'; +import { buildXmlString, createIdentifier } from './utils'; describe('CommonCartridgeUtils', () => { describe('buildXmlString', () => { @@ -19,17 +10,6 @@ describe('CommonCartridgeUtils', () => { }); }); - describe('createVersionNotSupportedError', () => { - describe('when creating error', () => { - it('should return error with message', () => { - const error = createVersionNotSupportedError(CommonCartridgeVersion.V_1_0_0); - - expect(error).toBeInstanceOf(InternalServerErrorException); - expect(error.message).toBe('Common Cartridge version 1.0.0 is not supported'); - }); - }); - }); - describe('createIdentifier', () => { describe('when creating identifier', () => { it('should return identifier with prefix', () => { @@ -43,42 +23,4 @@ describe('CommonCartridgeUtils', () => { }); }); }); - - describe('createResourceTypeNotSupportedError', () => { - describe('when creating error', () => { - it('should return error with message', () => { - const resourceType = 'unsupported'; - - const error = createResourceTypeNotSupportedError(resourceType); - - expect(error).toBeInstanceOf(InternalServerErrorException); - expect(error.message).toBe(`Common Cartridge resource type ${resourceType} is not supported`); - }); - }); - }); - - describe('createElementTypeNotSupportedError', () => { - describe('when creating error', () => { - it('should return error with message', () => { - const elementType = 'unsupported'; - - const error = createElementTypeNotSupportedError(elementType); - - expect(error).toBeInstanceOf(InternalServerErrorException); - expect(error.message).toBe(`Common Cartridge element type ${elementType} is not supported`); - }); - }); - }); - - describe('checkIntendedUse', () => { - describe('when intended use is not supported', () => { - it('should throw error', () => { - const supportedIntendedUses = ['use1', 'use2']; - - expect(() => checkIntendedUse('use3', supportedIntendedUses)).toThrowError( - 'Intended use use3 is not supported' - ); - }); - }); - }); }); diff --git a/apps/server/src/modules/common-cartridge/export/utils.ts b/apps/server/src/modules/common-cartridge/export/utils.ts index 0b110f783c5..0e22175216e 100644 --- a/apps/server/src/modules/common-cartridge/export/utils.ts +++ b/apps/server/src/modules/common-cartridge/export/utils.ts @@ -1,4 +1,3 @@ -import { InternalServerErrorException } from '@nestjs/common'; import { ObjectId } from 'bson'; import { Builder } from 'xml2js'; @@ -17,19 +16,6 @@ export function buildXmlString(obj: unknown): string { return xmlBuilder.buildObject(obj); } -export function createVersionNotSupportedError(version: string): Error { - return new InternalServerErrorException(`Common Cartridge version ${version} is not supported`); -} - -export function createResourceTypeNotSupportedError(type: string): Error { - return new InternalServerErrorException(`Common Cartridge resource type ${type} is not supported`); -} - -export function createElementTypeNotSupportedError(type: string): Error { - // AI next 1 line - return new InternalServerErrorException(`Common Cartridge element type ${type} is not supported`); -} - export function createIdentifier(identifier?: string | ObjectId): string { if (!identifier) { return `i${new ObjectId().toString()}`; @@ -37,9 +23,3 @@ export function createIdentifier(identifier?: string | ObjectId): string { return `i${identifier.toString()}`; } - -export function checkIntendedUse(intendedUse: string, supportedIntendedUses: string[]): void | never { - if (!supportedIntendedUses.includes(intendedUse)) { - throw new Error(`Intended use ${intendedUse} is not supported`); - } -} diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts index 6e57b5b98a6..847dfa66f35 100644 --- a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts @@ -51,7 +51,7 @@ export class CommonCartridgeFileParser { public getResource(organization: CommonCartridgeOrganizationProps): CommonCartridgeResourceProps | undefined { this.checkOrganization(organization); - const resource = this.resourceFactory.create(organization); + const resource = this.resourceFactory.create(organization, this.options.inputFormat); return resource; } diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts index 96152436f41..ce86755bb60 100644 --- a/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts @@ -1,13 +1,16 @@ +import { InputFormat } from '@shared/domain/types'; import { CommonCartridgeResourceTypeV1P1 } from './common-cartridge-import.enums'; export type CommonCartridgeFileParserOptions = { maxSearchDepth: number; pathSeparator: string; + inputFormat: InputFormat; }; export const DEFAULT_FILE_PARSER_OPTIONS: CommonCartridgeFileParserOptions = { maxSearchDepth: 5, pathSeparator: '/', + inputFormat: InputFormat.RICH_TEXT_CK5, }; export type CommonCartridgeOrganizationProps = { diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.spec.ts index 5bff23b9ce5..6f0d867681b 100644 --- a/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.spec.ts +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.spec.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker/locale/af_ZA'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { InputFormat } from '@shared/domain/types'; import AdmZip from 'adm-zip'; import { readFile } from 'node:fs/promises'; import { CommonCartridgeResourceTypeV1P1 } from './common-cartridge-import.enums'; @@ -81,7 +82,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should create a web link resource', async () => { const { organizationProps } = await setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toStrictEqual({ type: CommonCartridgeResourceTypeV1P1.WEB_LINK, @@ -105,7 +106,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should return undefined', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toBeUndefined(); }); @@ -123,7 +124,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should return undefined', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toBeUndefined(); }); @@ -142,7 +143,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should return undefined', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toBeUndefined(); }); @@ -163,12 +164,12 @@ describe('CommonCartridgeResourceFactory', () => { it('should create a web content resource', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toStrictEqual({ type: CommonCartridgeResourceTypeV1P1.WEB_CONTENT, title: organizationProps.title, - html: 'Content', + html: '

Content

', }); }); }); @@ -186,7 +187,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should return an empty value', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toStrictEqual({ type: CommonCartridgeResourceTypeV1P1.WEB_CONTENT, @@ -208,7 +209,7 @@ describe('CommonCartridgeResourceFactory', () => { it('should return undefined', () => { const { organizationProps } = setup(); - const result = sut.create(organizationProps); + const result = sut.create(organizationProps, InputFormat.RICH_TEXT_CK5); expect(result).toBeUndefined(); }); diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.ts index a759e36f136..b8058b59f95 100644 --- a/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.ts +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-resource-factory.ts @@ -1,3 +1,5 @@ +import { sanitizeRichText } from '@shared/controller'; +import { InputFormat } from '@shared/domain/types'; import AdmZip from 'adm-zip'; import { JSDOM } from 'jsdom'; import { CommonCartridgeResourceTypeV1P1 } from './common-cartridge-import.enums'; @@ -11,7 +13,10 @@ import { export class CommonCartridgeResourceFactory { constructor(private readonly archive: AdmZip) {} - public create(organization: CommonCartridgeOrganizationProps): CommonCartridgeResourceProps | undefined { + public create( + organization: CommonCartridgeOrganizationProps, + inputFormat: InputFormat + ): CommonCartridgeResourceProps | undefined { if (!this.isValidOrganization(organization)) { return undefined; } @@ -23,7 +28,7 @@ export class CommonCartridgeResourceFactory { case CommonCartridgeResourceTypeV1P1.WEB_LINK: return this.createWebLinkResource(content, title); case CommonCartridgeResourceTypeV1P1.WEB_CONTENT: - return this.createWebContentResource(content, title); + return this.createWebContentResource(content, title, inputFormat); default: return undefined; } @@ -52,14 +57,18 @@ export class CommonCartridgeResourceFactory { }; } - private createWebContentResource(content: string, title: string): CommonCartridgeWebContentResourceProps | undefined { + private createWebContentResource( + content: string, + title: string, + inputFormat: InputFormat + ): CommonCartridgeWebContentResourceProps | undefined { const document = this.tryCreateDocument(content, 'text/html'); if (!document) { return undefined; } - const html = document.body.textContent?.trim() ?? ''; + const html = sanitizeRichText(document.body.innerHTML?.trim() ?? '', inputFormat); return { type: CommonCartridgeResourceTypeV1P1.WEB_CONTENT, diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts index f9775a8e887..ed0b6e14ce8 100644 --- a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts @@ -28,6 +28,7 @@ describe('CommonCartridgeOrganizationVisitor', () => { const sut = new CommonCartridgeOrganizationVisitor(document, { maxSearchDepth: 1, pathSeparator: DEFAULT_FILE_PARSER_OPTIONS.pathSeparator, + inputFormat: DEFAULT_FILE_PARSER_OPTIONS.inputFormat, }); return { sut }; diff --git a/apps/server/src/modules/common-cartridge/index.ts b/apps/server/src/modules/common-cartridge/index.ts index 82b46a983b3..2f177a38fb6 100644 --- a/apps/server/src/modules/common-cartridge/index.ts +++ b/apps/server/src/modules/common-cartridge/index.ts @@ -1,11 +1,9 @@ export { CommonCartridgeFileBuilder, CommonCartridgeFileBuilderProps, + CommonCartridgeOrganizationProps, } from './export/builders/common-cartridge-file-builder'; -export { - CommonCartridgeOrganizationBuilder, - CommonCartridgeOrganizationBuilderOptions, -} from './export/builders/common-cartridge-organization-builder'; +export { CommonCartridgeOrganizationNode } from './export/builders/common-cartridge-organization-node'; export { CommonCartridgeElementType, CommonCartridgeIntendedUseType, @@ -19,8 +17,10 @@ export { CommonCartridgeFileParser } from './import/common-cartridge-file-parser export { CommonCartridgeResourceTypeV1P1 } from './import/common-cartridge-import.enums'; export { CommonCartridgeFileParserOptions, + CommonCartridgeOrganizationProps as CommonCartridgeImportOrganizationProps, CommonCartridgeResourceProps as CommonCartridgeImportResourceProps, - CommonCartridgeOrganizationProps, + CommonCartridgeWebContentResourceProps as CommonCartridgeImportWebContentResourceProps, + CommonCartridgeWebLinkResourceProps as CommonCartridgeImportWebLinkResourceProps, DEFAULT_FILE_PARSER_OPTIONS, } from './import/common-cartridge-import.types'; export { CommonCartridgeImportUtils } from './import/utils/common-cartridge-import-utils'; diff --git a/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts b/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts index 000ad586840..f6d05b9710b 100644 --- a/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts +++ b/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts @@ -1,5 +1,11 @@ import { faker } from '@faker-js/faker'; -import { CommonCartridgeElementType, CommonCartridgeVersion } from '@modules/common-cartridge'; +import { + CommonCartridgeElementProps, + CommonCartridgeElementType, + CommonCartridgeOrganizationProps, + CommonCartridgeVersion, +} from '@modules/common-cartridge'; +import { CommonCartridgeOrganizationNodeProps } from '../export/builders/common-cartridge-organization-node'; import { CommonCartridgeMetadataElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-metadata-element'; import { CommonCartridgeOrganizationElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-organization-element'; import { CommonCartridgeOrganizationsWrapperElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-organizations-wrapper-element'; @@ -94,3 +100,28 @@ export function createCommonCartridgeResourcesWrapperElementPropsV130( items: items || [], }; } + +export function createCommonCartridgeMetadataElementProps(): CommonCartridgeElementProps { + return { + type: CommonCartridgeElementType.METADATA, + title: faker.lorem.words(), + creationDate: new Date(), + copyrightOwners: ['John Doe', 'Jane Doe'], + }; +} + +export function createCommonCartridgeOrganizationProps(): CommonCartridgeOrganizationProps { + return { + title: faker.lorem.words(), + identifier: faker.string.uuid(), + }; +} + +export function createCommonCartridgeOrganizationNodeProps(): CommonCartridgeOrganizationNodeProps { + return { + type: CommonCartridgeElementType.ORGANIZATION, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + version: CommonCartridgeVersion.V_1_1_0, + }; +} diff --git a/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts b/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts index cbe4f31f0c6..136f0e8ac6f 100644 --- a/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts +++ b/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts @@ -1,16 +1,21 @@ import { faker } from '@faker-js/faker'; import { CommonCartridgeIntendedUseType, + CommonCartridgeResourceProps, CommonCartridgeResourceType, CommonCartridgeVersion, } from '@modules/common-cartridge'; -import { CommonCartridgeElement } from '../export/interfaces/common-cartridge-element.interface'; +import { CommonCartridgeElementFactory } from '../export/elements/common-cartridge-element-factory'; import { CommonCartridgeManifestResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-manifest-resource'; import { CommonCartridgeWebContentResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-web-content-resource'; import { CommonCartridgeWebLinkResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-web-link-resource'; import { CommonCartridgeManifestResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-manifest-resource'; import { CommonCartridgeWebContentResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-web-content-resource'; import { CommonCartridgeWebLinkResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-web-link-resource'; +import { + createCommonCartridgeMetadataElementPropsV110, + createCommonCartridgeMetadataElementPropsV130, +} from './common-cartridge-element-props.factory'; export function createCommonCartridgeWeblinkResourcePropsV110(): CommonCartridgeWebLinkResourcePropsV110 { return { @@ -63,7 +68,7 @@ export function createCommonCartridgeManifestResourcePropsV110(): CommonCartridg type: CommonCartridgeResourceType.MANIFEST, version: CommonCartridgeVersion.V_1_1_0, identifier: faker.string.uuid(), - metadata: {} as CommonCartridgeElement, + metadata: CommonCartridgeElementFactory.createElement(createCommonCartridgeMetadataElementPropsV110()), organizations: [], resources: [], }; @@ -74,8 +79,27 @@ export function createCommonCartridgeManifestResourcePropsV130(): CommonCartridg type: CommonCartridgeResourceType.MANIFEST, version: CommonCartridgeVersion.V_1_3_0, identifier: faker.string.uuid(), - metadata: {} as CommonCartridgeElement, + metadata: CommonCartridgeElementFactory.createElement(createCommonCartridgeMetadataElementPropsV130()), organizations: [], resources: [], }; } + +export function createCommonCartridgeWebLinkResourceProps(): CommonCartridgeResourceProps { + return { + type: CommonCartridgeResourceType.WEB_LINK, + title: faker.lorem.words(), + identifier: faker.string.uuid(), + url: faker.internet.url(), + }; +} + +export function createCommonCartridgeWebContentResourceProps(): CommonCartridgeResourceProps { + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + title: faker.lorem.words(), + identifier: faker.string.uuid(), + html: faker.lorem.paragraph(), + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; +} diff --git a/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts index e10e7488359..e4e5e06f259 100644 --- a/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts @@ -35,11 +35,10 @@ describe(`deletionExecution (api)`, () => { describe('executeDeletions', () => { describe('when execute deletionRequests with default limit', () => { - jest.setTimeout(20000); it('should return status 204', async () => { const response = await testApiClient.post(''); expect(response.status).toEqual(204); - }); + }, 20000); }); describe('without token', () => { diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts index 612d8a183f8..a1a6671358d 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts @@ -2,19 +2,20 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { MongoDatabaseModuleOptions } from '@infra/database/mongo-memory-database/types'; import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; -import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; +import { SystemEntity } from '@modules/system/entity'; import { HttpModule } from '@nestjs/axios'; import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Role, SchoolEntity, SchoolYearEntity, User } from '@shared/domain/entity'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; -import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; import { config, s3Config } from './fwu-learning-contents.config'; +import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; const imports = [ MongoMemoryDatabaseModule.forRoot({ diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts index 36e82ef130b..acf829e15d3 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts @@ -3,11 +3,12 @@ import { S3ClientModule } from '@infra/s3-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AuthorizationModule } from '@modules/authorization'; +import { SystemEntity } from '@modules/system/entity'; import { HttpModule } from '@nestjs/axios'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { Role, SchoolEntity, SchoolYearEntity, User } from '@shared/domain/entity'; +import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; 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 75c71ffad15..4bd62ffcf36 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 @@ -4,14 +4,7 @@ import { classEntityFactory } from '@modules/class/entity/testing'; import { serverConfig, ServerConfig, ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { - Course as CourseEntity, - Role, - SchoolEntity, - SchoolYearEntity, - SystemEntity, - User, -} from '@shared/domain/entity'; +import { Course as CourseEntity, Role, SchoolEntity, SchoolYearEntity, User } from '@shared/domain/entity'; import { RoleName, SortOrder } from '@shared/domain/interface'; import { courseFactory as courseEntityFactory, @@ -62,7 +55,7 @@ describe('Group (API)', () => { const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); - const system: SystemEntity = systemEntityFactory.buildWithId(); + const system = systemEntityFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', schoolId: school._id, diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts index 60adfbaec95..c63c83bbab6 100644 --- a/apps/server/src/modules/group/repo/group-domain.mapper.ts +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -1,7 +1,8 @@ import { EntityData } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { ExternalSource } from '@shared/domain/domainobject'; -import { ExternalSourceEmbeddable, Role, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ExternalSourceEmbeddable, Role, SchoolEntity, User } from '@shared/domain/entity'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable, GroupValidPeriodEmbeddable } from '../entity'; diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index f42f18bce33..6d5353ef171 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,8 +1,9 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { type SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { ExternalSource, Page } from '@shared/domain/domainobject'; -import { Course as CourseEntity, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Course as CourseEntity, SchoolEntity, User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts index 4e3f0d0ceee..1a9bb2c0abf 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -14,7 +14,7 @@ import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { School, SchoolService } from '@modules/school/domain'; import { schoolFactory } from '@modules/school/testing'; -import { LegacySystemService, SystemDto } from '@modules/system'; +import { System, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -27,6 +27,7 @@ import { roleDtoFactory, schoolYearFactory, setupEntities, + systemFactory, UserAndAccountTestFactory, userDoFactory, userFactory, @@ -46,7 +47,7 @@ describe('ClassGroupUc', () => { let userService: DeepMocked; let roleService: DeepMocked; let classService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let schoolService: DeepMocked; let authorizationService: DeepMocked; let schoolYearService: DeepMocked; @@ -74,8 +75,8 @@ describe('ClassGroupUc', () => { useValue: createMock(), }, { - provide: LegacySystemService, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: SchoolService, @@ -105,7 +106,7 @@ describe('ClassGroupUc', () => { userService = module.get(UserService); roleService = module.get(RoleService); classService = module.get(ClassService); - systemService = module.get(LegacySystemService); + systemService = module.get(SystemService); schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); schoolYearService = module.get(SchoolYearService); @@ -196,10 +197,8 @@ describe('ClassGroupUc', () => { year: undefined, }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), + const system: System = systemFactory.withOauthConfig().build({ displayName: 'External System', - type: 'oauth2', }); const group: Group = groupFactory.build({ name: 'B', @@ -611,10 +610,8 @@ describe('ClassGroupUc', () => { source: 'LDAP', year: schoolYear.id, }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), + const system: System = systemFactory.withOauthConfig().build({ displayName: 'External System', - type: 'oauth2', }); const group: Group = groupFactory.build({ name: 'B', @@ -902,10 +899,8 @@ describe('ClassGroupUc', () => { source: 'LDAP', year: schoolYear.id, }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), + const system: System = systemFactory.withOauthConfig().build({ displayName: 'External System', - type: 'oauth2', }); const group: Group = groupFactory.build({ name: 'B', diff --git a/apps/server/src/modules/group/uc/class-group.uc.ts b/apps/server/src/modules/group/uc/class-group.uc.ts index 5f7843c3885..767591735fe 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.ts @@ -13,7 +13,7 @@ import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; import { Pagination, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { LegacySystemService, SystemDto } from '@src/modules/system'; +import { System, SystemService } from '@src/modules/system'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupFilter } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; @@ -26,7 +26,7 @@ export class ClassGroupUc { constructor( private readonly groupService: GroupService, private readonly classService: ClassService, - private readonly systemService: LegacySystemService, + private readonly systemService: SystemService, private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, private readonly schoolYearService: SchoolYearService, @@ -236,7 +236,7 @@ export class ClassGroupUc { } private async getClassInfosFromGroups(groups: Group[]): Promise { - const systemMap: Map = await this.findSystemNamesForGroups(groups); + const systemMap: Map = await this.findSystemNamesForGroups(groups); const classInfosFromGroups: ClassInfoDto[] = await Promise.all( groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) @@ -245,8 +245,8 @@ export class ClassGroupUc { return classInfosFromGroups; } - private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { - let system: SystemDto | undefined; + private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { + let system: System | undefined; if (group.externalSource) { system = systemMap.get(group.externalSource.systemId); } @@ -268,20 +268,22 @@ export class ClassGroupUc { return mapped; } - private async findSystemNamesForGroups(groups: Group[]): Promise> { + private async findSystemNamesForGroups(groups: Group[]): Promise> { const systemIds: EntityId[] = groups .map((group: Group): string | undefined => group.externalSource?.systemId) .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); - const systems: Map = new Map(); + const systems: Map = new Map(); await Promise.all( uniqueSystemIds.map(async (systemId: string): Promise => { - const system: SystemDto = await this.systemService.findById(systemId); + const system: System | null = await this.systemService.findById(systemId); - systems.set(systemId, system); + if (system) { + systems.set(systemId, system); + } }) ); 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 cded865cb00..c03258d21ce 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -17,6 +17,7 @@ import { groupFactory, roleDtoFactory, roleFactory, + schoolEntityFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, @@ -327,7 +328,10 @@ describe('GroupUc', () => { const school: School = schoolFactory.build(); const otherSchool: School = schoolFactory.build(); const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_FULL_ADMIN, Permission.GROUP_VIEW] }); - const user: User = userFactory.buildWithId({ roles: [roles], school }); + const user: User = userFactory.buildWithId({ + roles: [roles], + school: schoolEntityFactory.buildWithId(undefined, school.id), + }); const groupInSchool: Group = groupFactory.build({ organizationId: school.id }); const availableGroupInSchool: Group = groupFactory.build({ organizationId: school.id }); @@ -493,7 +497,10 @@ describe('GroupUc', () => { const setup = () => { const school: School = schoolFactory.build(); const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_VIEW] }); - const user: User = userFactory.buildWithId({ roles: [roles], school }); + const user: User = userFactory.buildWithId({ + roles: [roles], + school: schoolEntityFactory.buildWithId(undefined, school.id), + }); const teachersGroup: Group = groupFactory.build({ organizationId: school.id, diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts index 9dee917acdd..d0da50c17eb 100644 --- a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -1,5 +1,5 @@ import { Class } from '@modules/class/domain'; -import { SystemDto } from '@modules/system'; +import { System } from '@modules/system'; import { UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; @@ -13,7 +13,7 @@ export class GroupUcMapper { group: Group, resolvedUsers: ResolvedGroupUser[], synchronizedCourses: Course[], - system?: SystemDto + system?: System ): ClassInfoDto { const mapped: ClassInfoDto = new ClassInfoDto({ id: group.id, diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index 3627b94b129..4ed7eae9fef 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -3,7 +3,6 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; import { TaskModule } from '@modules/task'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; -import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { @@ -18,8 +17,8 @@ import { import { LoggerModule } from '@src/core/logger'; import { BoardNodeRepo } from '../board/repo'; import { COURSE_REPO } from './domain'; +import { CommonCartridgeExportMapper } from './mapper/common-cartridge-export.mapper'; import { CommonCartridgeImportMapper } from './mapper/common-cartridge-import.mapper'; -import { CommonCartridgeMapper } from './mapper/common-cartridge.mapper'; import { ColumnBoardNodeRepo } from './repo'; import { CourseMikroOrmRepo } from './repo/mikro-orm/course.repo'; import { @@ -44,7 +43,6 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; LessonModule, LoggerModule, TaskModule, - ToolConfigModule, CqrsModule, ], providers: [ @@ -57,7 +55,7 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; CommonCartridgeExportService, CommonCartridgeFileValidatorPipe, CommonCartridgeImportService, - CommonCartridgeMapper, + CommonCartridgeExportMapper, CommonCartridgeImportMapper, CourseCopyService, CourseGroupRepo, diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.spec.ts similarity index 96% rename from apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts rename to apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.spec.ts index 2fe6f0967a1..0e4bdc92e82 100644 --- a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.spec.ts @@ -5,7 +5,7 @@ import { CommonCartridgeElementType, CommonCartridgeFileBuilderProps, CommonCartridgeIntendedUseType, - CommonCartridgeOrganizationBuilderOptions, + CommonCartridgeOrganizationProps, CommonCartridgeResourceProps, CommonCartridgeResourceType, CommonCartridgeVersion, @@ -18,25 +18,25 @@ import { ComponentProperties, ComponentType } from '@shared/domain/entity'; import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; import { linkElementFactory, richTextElementFactory } from '@modules/board/testing'; import { LearnroomConfig } from '../learnroom.config'; -import { CommonCartridgeMapper } from './common-cartridge.mapper'; +import { CommonCartridgeExportMapper } from './common-cartridge-export.mapper'; -describe('CommonCartridgeMapper', () => { +describe('CommonCartridgeExportMapper', () => { let module: TestingModule; - let sut: CommonCartridgeMapper; + let sut: CommonCartridgeExportMapper; let configServiceMock: DeepMocked>; beforeAll(async () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ - CommonCartridgeMapper, + CommonCartridgeExportMapper, { provide: ConfigService, useValue: createMock>(), }, ], }).compile(); - sut = module.get(CommonCartridgeMapper); + sut = module.get(CommonCartridgeExportMapper); configServiceMock = module.get(ConfigService); }); @@ -88,7 +88,7 @@ describe('CommonCartridgeMapper', () => { const { lesson } = setup(); const organizationProps = sut.mapLessonToOrganization(lesson); - expect(organizationProps).toStrictEqual>({ + expect(organizationProps).toStrictEqual>({ identifier: createIdentifier(lesson.id), title: lesson.name, }); @@ -117,7 +117,7 @@ describe('CommonCartridgeMapper', () => { const { componentProps } = setup(); const organizationProps = sut.mapContentToOrganization(componentProps); - expect(organizationProps).toStrictEqual>({ + expect(organizationProps).toStrictEqual>({ identifier: expect.any(String), title: componentProps.title, }); @@ -188,7 +188,7 @@ describe('CommonCartridgeMapper', () => { const { task } = setup(); const organizationProps = sut.mapTaskToOrganization(task); - expect(organizationProps).toStrictEqual>({ + expect(organizationProps).toStrictEqual>({ identifier: expect.any(String), title: task.name, }); @@ -415,10 +415,7 @@ describe('CommonCartridgeMapper', () => { expect(resourceProps).toStrictEqual({ type: CommonCartridgeResourceType.WEB_CONTENT, identifier: expect.any(String), - title: richTextElement.text - .slice(0, 50) - .replace(/<[^>]*>?/gm, '') - .concat('...'), + title: richTextElement.text.slice(0, 50).replace(/<[^>]*>?/gm, ''), html: `

${richTextElement.text}

`, intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, }); diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.ts similarity index 88% rename from apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts rename to apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.ts index 3d684905903..989d230c3eb 100644 --- a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-export.mapper.ts @@ -2,21 +2,21 @@ import { LinkElement, RichTextElement } from '@modules/board/domain'; import { CommonCartridgeElementProps, CommonCartridgeElementType, - CommonCartridgeFileBuilderProps, CommonCartridgeIntendedUseType, - CommonCartridgeOrganizationBuilderOptions, CommonCartridgeResourceProps, CommonCartridgeResourceType, CommonCartridgeVersion, createIdentifier, } from '@modules/common-cartridge'; +import { CommonCartridgeOrganizationProps } from '@modules/common-cartridge/export/builders/common-cartridge-file-builder'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ComponentProperties, ComponentType, Course, LessonEntity, Task } from '@shared/domain/entity'; +import sanitizeHtml from 'sanitize-html'; import { LearnroomConfig } from '../learnroom.config'; @Injectable() -export class CommonCartridgeMapper { +export class CommonCartridgeExportMapper { constructor(private readonly configService: ConfigService) {} public mapCourseToMetadata(course: Course): CommonCartridgeElementProps { @@ -28,21 +28,21 @@ export class CommonCartridgeMapper { }; } - public mapLessonToOrganization(lesson: LessonEntity): CommonCartridgeOrganizationBuilderOptions { + public mapLessonToOrganization(lesson: LessonEntity): CommonCartridgeOrganizationProps { return { identifier: createIdentifier(lesson.id), title: lesson.name, }; } - public mapContentToOrganization(content: ComponentProperties): CommonCartridgeOrganizationBuilderOptions { + public mapContentToOrganization(content: ComponentProperties): CommonCartridgeOrganizationProps { return { identifier: createIdentifier(content._id), title: content.title, }; } - public mapTaskToOrganization(task: Task): CommonCartridgeOrganizationBuilderOptions { + public mapTaskToOrganization(task: Task): CommonCartridgeOrganizationProps { return { identifier: createIdentifier(), title: task.name, @@ -112,7 +112,10 @@ export class CommonCartridgeMapper { } } - public mapCourseToManifest(version: CommonCartridgeVersion, course: Course): CommonCartridgeFileBuilderProps { + public mapCourseToManifest( + version: CommonCartridgeVersion, + course: Course + ): { version: CommonCartridgeVersion; identifier: string } { return { version, identifier: createIdentifier(course.id), @@ -139,10 +142,11 @@ export class CommonCartridgeMapper { } private getTextTitle(text: string): string { - const title = text - .slice(0, 50) - .replace(/<[^>]*>?/gm, '') - .concat('...'); + const title = sanitizeHtml(text, { + allowedTags: [], + allowedAttributes: {}, + }).slice(0, 50); + return title; } } diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts index c9a4f0d544f..a2515c4d367 100644 --- a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts @@ -1,12 +1,11 @@ import { faker } from '@faker-js/faker'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ContentElementType } from '@modules/board/domain'; -import { LinkContentBody, RichTextContentBody } from '@modules/board/controller/dto'; +import { ContentElementType, LinkContentBody, RichTextContentBody } from '@modules/board'; import { + CommonCartridgeImportOrganizationProps, CommonCartridgeImportResourceProps, - CommonCartridgeOrganizationProps, CommonCartridgeResourceTypeV1P1, } from '@modules/common-cartridge'; +import { Test, TestingModule } from '@nestjs/testing'; import { InputFormat } from '@shared/domain/types'; import { CommonCartridgeImportMapper } from './common-cartridge-import.mapper'; @@ -15,7 +14,7 @@ describe('CommonCartridgeImportMapper', () => { let sut: CommonCartridgeImportMapper; const setupOrganization = () => { - const organization: CommonCartridgeOrganizationProps = { + const organization: CommonCartridgeImportOrganizationProps = { path: faker.string.uuid(), pathDepth: faker.number.int({ min: 0, max: 3 }), identifier: faker.string.uuid(), diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts index e199bf466a9..826186cd4e8 100644 --- a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts @@ -1,13 +1,13 @@ +import { AnyElementContentBody, ContentElementType, LinkContentBody, RichTextContentBody } from '@modules/board'; +import { + CommonCartridgeImportResourceProps, + CommonCartridgeImportWebContentResourceProps, + CommonCartridgeImportWebLinkResourceProps, + CommonCartridgeOrganizationProps, + CommonCartridgeResourceTypeV1P1, +} from '@modules/common-cartridge'; import { Injectable } from '@nestjs/common'; -import { ContentElementType } from '@modules/board/domain'; import { InputFormat } from '@shared/domain/types'; -import { AnyElementContentBody, LinkContentBody, RichTextContentBody } from '@modules/board/controller/dto'; -import { CommonCartridgeOrganizationProps, CommonCartridgeResourceTypeV1P1 } from '@modules/common-cartridge'; -import { - CommonCartridgeResourceProps, - CommonCartridgeWebContentResourceProps, - CommonCartridgeWebLinkResourceProps, -} from '@src/modules/common-cartridge/import/common-cartridge-import.types'; @Injectable() export class CommonCartridgeImportMapper { @@ -45,7 +45,7 @@ export class CommonCartridgeImportMapper { } } - public mapResourceToContentElementBody(resource: CommonCartridgeResourceProps): AnyElementContentBody { + public mapResourceToContentElementBody(resource: CommonCartridgeImportResourceProps): AnyElementContentBody { switch (resource.type) { case CommonCartridgeResourceTypeV1P1.WEB_LINK: return this.createLinkContentElementBody(resource); @@ -56,7 +56,7 @@ export class CommonCartridgeImportMapper { } } - private createLinkContentElementBody(resource: CommonCartridgeWebLinkResourceProps): AnyElementContentBody { + private createLinkContentElementBody(resource: CommonCartridgeImportWebLinkResourceProps): AnyElementContentBody { const body = new LinkContentBody(); body.title = resource.title; @@ -65,7 +65,7 @@ export class CommonCartridgeImportMapper { return body; } - private createWebContentElementBody(resource: CommonCartridgeWebContentResourceProps): AnyElementContentBody { + private createWebContentElementBody(resource: CommonCartridgeImportWebContentResourceProps): AnyElementContentBody { const body = new RichTextContentBody(); body.text = resource.html; diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts index 605d9ad3cdc..4cd4ff7fab0 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts @@ -1,5 +1,13 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ColumnBoardService } from '@modules/board'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + linkElementFactory, + richTextElementFactory, +} from '@modules/board/testing'; import { CommonCartridgeVersion } from '@modules/common-cartridge'; import { CommonCartridgeExportService, CourseService, LearnroomConfig } from '@modules/learnroom'; import { LessonService } from '@modules/lesson'; @@ -8,16 +16,8 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { ComponentType } from '@shared/domain/entity'; import { courseFactory, lessonFactory, setupEntities, taskFactory } from '@shared/testing'; -import { ColumnBoardService } from '@src/modules/board'; -import { - cardFactory, - columnBoardFactory, - columnFactory, - linkElementFactory, - richTextElementFactory, -} from '@src/modules/board/testing'; import AdmZip from 'adm-zip'; -import { CommonCartridgeMapper } from '../mapper/common-cartridge.mapper'; +import { CommonCartridgeExportMapper } from '../mapper/common-cartridge-export.mapper'; describe('CommonCartridgeExportService', () => { let module: TestingModule; @@ -107,7 +107,7 @@ describe('CommonCartridgeExportService', () => { module = await Test.createTestingModule({ providers: [ CommonCartridgeExportService, - CommonCartridgeMapper, + CommonCartridgeExportMapper, { provide: CourseService, useValue: createMock(), diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts index 4c146bcfeb6..3300d60af1c 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts @@ -3,24 +3,24 @@ import { BoardExternalReferenceType, Card, Column, + ColumnBoardService, isCard, isColumn, isLinkElement, isRichTextElement, -} from '@modules/board/domain'; +} from '@modules/board'; import { CommonCartridgeFileBuilder, - CommonCartridgeOrganizationBuilder, + CommonCartridgeOrganizationNode, CommonCartridgeVersion, + createIdentifier, } from '@modules/common-cartridge'; import { LessonService } from '@modules/lesson'; import { TaskService } from '@modules/task'; import { Injectable } from '@nestjs/common'; import { ComponentProperties } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { ColumnBoardService } from '@src/modules/board'; -import { createIdentifier } from '@src/modules/common-cartridge/export/utils'; -import { CommonCartridgeMapper } from '../mapper/common-cartridge.mapper'; +import { CommonCartridgeExportMapper } from '../mapper/common-cartridge-export.mapper'; import { CourseService } from './course.service'; @Injectable() @@ -30,7 +30,7 @@ export class CommonCartridgeExportService { private readonly lessonService: LessonService, private readonly taskService: TaskService, private readonly columnBoardService: ColumnBoardService, - private readonly commonCartridgeMapper: CommonCartridgeMapper + private readonly mapper: CommonCartridgeExportMapper ) {} public async exportCourse( @@ -42,9 +42,9 @@ export class CommonCartridgeExportService { exportedColumnBoards: string[] ): Promise { const course = await this.courseService.findById(courseId); - const builder = new CommonCartridgeFileBuilder(this.commonCartridgeMapper.mapCourseToManifest(version, course)); + const builder = new CommonCartridgeFileBuilder(this.mapper.mapCourseToManifest(version, course)); - builder.addMetadata(this.commonCartridgeMapper.mapCourseToMetadata(course)); + builder.addMetadata(this.mapper.mapCourseToMetadata(course)); await this.addLessons(builder, courseId, version, exportedTopics); await this.addTasks(builder, courseId, userId, version, exportedTasks); @@ -66,14 +66,14 @@ export class CommonCartridgeExportService { return; } - const organizationBuilder = builder.addOrganization(this.commonCartridgeMapper.mapLessonToOrganization(lesson)); + const lessonOrganization = builder.createOrganization(this.mapper.mapLessonToOrganization(lesson)); lesson.contents.forEach((content) => { - this.addComponentToOrganization(organizationBuilder, content); + this.addComponentToOrganization(content, lessonOrganization); }); lesson.getLessonLinkedTasks().forEach((task) => { - organizationBuilder.addResource(this.commonCartridgeMapper.mapTaskToResource(task, version)); + lessonOrganization.addResource(this.mapper.mapTaskToResource(task, version)); }); }); } @@ -91,8 +91,8 @@ export class CommonCartridgeExportService { return; } - const organization = builder.addOrganization({ - title: '', + const tasksOrganization = builder.createOrganization({ + title: 'Aufgaben', identifier: createIdentifier(), }); @@ -101,7 +101,7 @@ export class CommonCartridgeExportService { return; } - organization.addResource(this.commonCartridgeMapper.mapTaskToResource(task, version)); + tasksOrganization.addResource(this.mapper.mapTaskToResource(task, version)); }); } @@ -118,21 +118,21 @@ export class CommonCartridgeExportService { ).filter((cb) => exportedColumnBoards.includes(cb.id)); for (const columnBoard of columnBoards) { - const organization = builder.addOrganization({ + const columnBoardOrganization = builder.createOrganization({ title: columnBoard.title, identifier: createIdentifier(columnBoard.id), }); columnBoard.children .filter((child) => isColumn(child)) - .forEach((column) => this.addColumnToOrganization(column as Column, organization)); + .forEach((column) => this.addColumnToOrganization(column as Column, columnBoardOrganization)); } } - private addColumnToOrganization(column: Column, organizationBuilder: CommonCartridgeOrganizationBuilder): void { + private addColumnToOrganization(column: Column, columnBoardOrganization: CommonCartridgeOrganizationNode): void { const { id } = column; - const columnOrganization = organizationBuilder.addSubOrganization({ - title: column.title ?? '', + const columnOrganization = columnBoardOrganization.createChild({ + title: column.title || '', identifier: createIdentifier(id), }); @@ -141,49 +141,43 @@ export class CommonCartridgeExportService { .forEach((card) => this.addCardToOrganization(card as Card, columnOrganization)); } - private addCardToOrganization(card: Card, organizationBuilder: CommonCartridgeOrganizationBuilder): void { - const { id } = card; - const cardOrganization = organizationBuilder.addSubOrganization({ - title: card.title ?? '', - identifier: createIdentifier(id), + private addCardToOrganization(card: Card, columnOrganization: CommonCartridgeOrganizationNode): void { + const cardOrganization = columnOrganization.createChild({ + title: card.title || '', + identifier: createIdentifier(card.id), }); card.children.forEach((child) => this.addCardElementToOrganization(child, cardOrganization)); } - private addCardElementToOrganization( - element: AnyBoardNode, - organizationBuilder: CommonCartridgeOrganizationBuilder - ): void { + private addCardElementToOrganization(element: AnyBoardNode, cardOrganization: CommonCartridgeOrganizationNode): void { if (isRichTextElement(element)) { - const resource = this.commonCartridgeMapper.mapRichTextElementToResource(element); + const resource = this.mapper.mapRichTextElementToResource(element); - organizationBuilder.addResource(resource); + cardOrganization.addResource(resource); } if (isLinkElement(element)) { - const resource = this.commonCartridgeMapper.mapLinkElementToResource(element); + const resource = this.mapper.mapLinkElementToResource(element); - organizationBuilder.addResource(resource); + cardOrganization.addResource(resource); } } private addComponentToOrganization( - organizationBuilder: CommonCartridgeOrganizationBuilder, - component: ComponentProperties + component: ComponentProperties, + lessonOrganization: CommonCartridgeOrganizationNode ): void { - const resources = this.commonCartridgeMapper.mapContentToResources(component); + const resources = this.mapper.mapContentToResources(component); if (Array.isArray(resources)) { - const subOrganizationBuilder = organizationBuilder.addSubOrganization( - this.commonCartridgeMapper.mapContentToOrganization(component) - ); + const componentOrganization = lessonOrganization.createChild(this.mapper.mapContentToOrganization(component)); resources.forEach((resource) => { - subOrganizationBuilder.addResource(resource); + componentOrganization.addResource(resource); }); } else { - organizationBuilder.addResource(resources); + lessonOrganization.addResource(resources); } } } diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts index 1967d3e51a8..6607bcb5784 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts @@ -1,8 +1,10 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { MikroORM } from '@mikro-orm/core'; import { Test, TestingModule } from '@nestjs/testing'; +import { InputFormat } from '@shared/domain/types'; import { setupEntities, userFactory } from '@shared/testing'; import { BoardNodeFactory, BoardNodeService } from '@src/modules/board'; +import { LinkElement, RichTextElement } from '@src/modules/board/domain'; import { readFile } from 'fs/promises'; import { CommonCartridgeImportMapper } from '../mapper/common-cartridge-import.mapper'; import { CommonCartridgeImportService } from './common-cartridge-import.service'; @@ -13,23 +15,42 @@ describe('CommonCartridgeImportService', () => { let moduleRef: TestingModule; let sut: CommonCartridgeImportService; let courseServiceMock: DeepMocked; - let boardNodeFactoryMock: DeepMocked; + let boardNodeFactory: BoardNodeFactory; let boardNodeServiceMock: DeepMocked; + const courseName = 'Test Kurs'; + + const board1Title = 'Test Thema'; + const board2Title = ''; + const board3Title = 'Spaltenboard 1'; + + const column1ofBoard1Title = 'Test Text'; + const column1ofBoard2Title = 'Test Aufgabe'; + const column1ofBoard3Title = 'Spalte 1'; + const column2ofBoard3Title = 'Spalte 2'; + const column3ofBoard3Title = 'Spalte 3'; + const column4ofBoard3Title = 'Spalte 4'; + + const emptyCardTitle = ''; + const card1Title = 'Karte 1'; + const card2Title = 'Karte 2'; + const card3Title = 'Karte 3'; + const card4Title = 'Karte 4'; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const objectContainingTitle = (title: string) => expect.objectContaining({ title }); + beforeEach(async () => { orm = await setupEntities(); moduleRef = await Test.createTestingModule({ providers: [ CommonCartridgeImportService, CommonCartridgeImportMapper, + BoardNodeFactory, { provide: CourseService, useValue: createMock(), }, - { - provide: BoardNodeFactory, - useValue: createMock(), - }, { provide: BoardNodeService, useValue: createMock(), @@ -39,7 +60,7 @@ describe('CommonCartridgeImportService', () => { sut = moduleRef.get(CommonCartridgeImportService); courseServiceMock = moduleRef.get(CourseService); - boardNodeFactoryMock = moduleRef.get(BoardNodeFactory); + boardNodeFactory = moduleRef.get(BoardNodeFactory); boardNodeServiceMock = moduleRef.get(BoardNodeService); }); @@ -56,67 +77,19 @@ describe('CommonCartridgeImportService', () => { expect(sut).toBeDefined(); }); - const setupEnvironment = async (filePath: string) => { - const user = userFactory.buildWithId(); - const buffer = await readFile(filePath); - - return { user, buffer }; - }; - describe('importFile', () => { - describe('when the common cartridge is valid', () => { - const setup = async () => - setupEnvironment('./apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc'); - - it('should create a course', async () => { - const { user, buffer } = await setup(); - - await sut.importFile(user, buffer); - - expect(courseServiceMock.create).toHaveBeenCalledTimes(1); - }); - - it('should create a column board', async () => { - const { user, buffer } = await setup(); - - await sut.importFile(user, buffer); - - expect(boardNodeFactoryMock.buildColumnBoard).toHaveBeenCalledTimes(14); - expect(boardNodeServiceMock.addRoot).toHaveBeenCalledTimes(14); - }); - - it('should create columns', async () => { - const { user, buffer } = await setup(); - - await sut.importFile(user, buffer); - - expect(boardNodeFactoryMock.buildColumn).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); - }); - - it('should create cards', async () => { - const { user, buffer } = await setup(); - - await sut.importFile(user, buffer); - - expect(boardNodeFactoryMock.buildCard).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); - }); - - it('should create elements', async () => { - const { user, buffer } = await setup(); + describe('when the common cartridge is a valid dbc course', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const buffer = await readFile('./apps/server/src/modules/common-cartridge/testing/assets/dbc_course.imscc'); - await sut.importFile(user, buffer); + const spyBuildColumnBoard = jest.spyOn(boardNodeFactory, 'buildColumnBoard'); + const spyBuildColumn = jest.spyOn(boardNodeFactory, 'buildColumn'); + const spyBuildCard = jest.spyOn(boardNodeFactory, 'buildCard'); + const spyBuildContentElement = jest.spyOn(boardNodeFactory, 'buildContentElement'); - expect(boardNodeFactoryMock.buildContentElement).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); - expect(boardNodeServiceMock.updateContent).toHaveBeenCalled(); - }); - }); - - describe('when the common cartridge is a valid dbc course', () => { - const setup = async () => - setupEnvironment('./apps/server/src/modules/common-cartridge/testing/assets/dbc_course.imscc'); + return { user, buffer, spyBuildColumnBoard, spyBuildColumn, spyBuildCard, spyBuildContentElement }; + }; it('should create a course', async () => { const { user, buffer } = await setup(); @@ -124,43 +97,140 @@ describe('CommonCartridgeImportService', () => { await sut.importFile(user, buffer); expect(courseServiceMock.create).toHaveBeenCalledTimes(1); + expect(courseServiceMock.create).toHaveBeenCalledWith(expect.objectContaining({ name: courseName })); }); it('should create a column board', async () => { - const { user, buffer } = await setup(); + const { user, buffer, spyBuildColumnBoard } = await setup(); await sut.importFile(user, buffer); - expect(boardNodeFactoryMock.buildColumnBoard).toHaveBeenCalledTimes(3); - expect(boardNodeServiceMock.addRoot).toHaveBeenCalledTimes(3); + expect(spyBuildColumnBoard).toHaveBeenCalledTimes(3); + + expect(boardNodeServiceMock.addRoot).toHaveBeenCalledWith(objectContainingTitle(board1Title)); + expect(boardNodeServiceMock.addRoot).toHaveBeenCalledWith(objectContainingTitle(board2Title)); + expect(boardNodeServiceMock.addRoot).toHaveBeenCalledWith(objectContainingTitle(board3Title)); }); it('should create columns', async () => { - const { user, buffer } = await setup(); + const { user, buffer, spyBuildColumn } = await setup(); await sut.importFile(user, buffer); - expect(boardNodeFactoryMock.buildColumn).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); + expect(spyBuildColumn).toHaveBeenCalledTimes(6); + + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board1Title), + objectContainingTitle(column1ofBoard1Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board2Title), + objectContainingTitle(column1ofBoard2Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board3Title), + objectContainingTitle(column1ofBoard3Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board3Title), + objectContainingTitle(column2ofBoard3Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board3Title), + objectContainingTitle(column3ofBoard3Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(board3Title), + objectContainingTitle(column4ofBoard3Title) + ); }); it('should create cards', async () => { - const { user, buffer } = await setup(); + const { user, buffer, spyBuildCard } = await setup(); await sut.importFile(user, buffer); - expect(boardNodeFactoryMock.buildCard).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); + expect(spyBuildCard).toHaveBeenCalledTimes(6); + + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column1ofBoard1Title), + objectContainingTitle(emptyCardTitle) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column1ofBoard2Title), + objectContainingTitle(emptyCardTitle) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column1ofBoard3Title), + objectContainingTitle(card1Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column2ofBoard3Title), + objectContainingTitle(card2Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column3ofBoard3Title), + objectContainingTitle(card3Title) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(column4ofBoard3Title), + objectContainingTitle(card4Title) + ); }); it('should create elements', async () => { - const { user, buffer } = await setup(); + const { user, buffer, spyBuildContentElement } = await setup(); await sut.importFile(user, buffer); - expect(boardNodeFactoryMock.buildContentElement).toHaveBeenCalled(); - expect(boardNodeServiceMock.addToParent).toHaveBeenCalled(); - expect(boardNodeServiceMock.updateContent).toHaveBeenCalled(); + expect(spyBuildContentElement).toHaveBeenCalledTimes(6); + + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(emptyCardTitle), + expect.any(RichTextElement) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(card1Title), + expect.any(RichTextElement) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(card2Title), + expect.any(LinkElement) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(card3Title), + expect.any(LinkElement) + ); + expect(boardNodeServiceMock.addToParent).toHaveBeenCalledWith( + objectContainingTitle(card4Title), + expect.any(RichTextElement) + ); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledTimes(6); + + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(RichTextElement), { + text: 'Test Text

Dies ist ein Textinhalt.

', + inputFormat: InputFormat.RICH_TEXT_CK5_SIMPLE, + }); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(RichTextElement), { + text: 'Test Aufgabe

', + inputFormat: InputFormat.RICH_TEXT_CK5_SIMPLE, + }); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(RichTextElement), { + text: '

Karteninhalt von Karte 1

', + inputFormat: InputFormat.RICH_TEXT_CK5_SIMPLE, + }); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(LinkElement), { + title: 'Example Domain', + url: 'https://www.example.org/', + }); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(LinkElement), { + title: 'Karte 3', + url: 'https://www.example.org/', + }); + expect(boardNodeServiceMock.updateContent).toHaveBeenCalledWith(expect.any(RichTextElement), { + text: 'Example Domain', + inputFormat: 'richTextCk5Simple', + }); }); }); }); diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts index 1cded6df376..c57b7300510 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts @@ -2,19 +2,19 @@ import { BoardExternalReferenceType, BoardLayout, BoardNodeFactory, + BoardNodeService, Card, - ContentElementType, Column, ColumnBoard, -} from '@modules/board/domain'; -import { BoardNodeService } from '@modules/board/service'; -import { Injectable } from '@nestjs/common'; -import { Course, User } from '@shared/domain/entity'; + ContentElementType, +} from '@modules/board'; import { CommonCartridgeFileParser, - CommonCartridgeOrganizationProps, + CommonCartridgeImportOrganizationProps, DEFAULT_FILE_PARSER_OPTIONS, -} from '@src/modules/common-cartridge'; +} from '@modules/common-cartridge'; +import { Injectable } from '@nestjs/common'; +import { Course, User } from '@shared/domain/entity'; import { CommonCartridgeImportMapper } from '../mapper/common-cartridge-import.mapper'; import { CourseService } from './course.service'; @@ -47,8 +47,8 @@ export class CommonCartridgeImportService { private async createColumnBoard( parser: CommonCartridgeFileParser, course: Course, - boardProps: CommonCartridgeOrganizationProps, - organizations: CommonCartridgeOrganizationProps[] + boardProps: CommonCartridgeImportOrganizationProps, + organizations: CommonCartridgeImportOrganizationProps[] ): Promise { const columnBoard = this.boardNodeFactory.buildColumnBoard({ context: { @@ -66,8 +66,8 @@ export class CommonCartridgeImportService { private async createColumns( parser: CommonCartridgeFileParser, columnBoard: ColumnBoard, - boardProps: CommonCartridgeOrganizationProps, - organizations: CommonCartridgeOrganizationProps[] + boardProps: CommonCartridgeImportOrganizationProps, + organizations: CommonCartridgeImportOrganizationProps[] ): Promise { const columnsWithResource = organizations.filter( (organization) => @@ -91,7 +91,7 @@ export class CommonCartridgeImportService { private async createColumnWithResource( parser: CommonCartridgeFileParser, columnBoard: ColumnBoard, - columnProps: CommonCartridgeOrganizationProps + columnProps: CommonCartridgeImportOrganizationProps ): Promise { const column = this.boardNodeFactory.buildColumn(); const { title } = this.mapper.mapOrganizationToColumn(columnProps); @@ -103,8 +103,8 @@ export class CommonCartridgeImportService { private async createColumn( parser: CommonCartridgeFileParser, columnBoard: ColumnBoard, - columnProps: CommonCartridgeOrganizationProps, - organizations: CommonCartridgeOrganizationProps[] + columnProps: CommonCartridgeImportOrganizationProps, + organizations: CommonCartridgeImportOrganizationProps[] ): Promise { const column = this.boardNodeFactory.buildColumn(); const { title } = this.mapper.mapOrganizationToColumn(columnProps); @@ -131,7 +131,7 @@ export class CommonCartridgeImportService { private async createCardWithElement( parser: CommonCartridgeFileParser, column: Column, - cardProps: CommonCartridgeOrganizationProps, + cardProps: CommonCartridgeImportOrganizationProps, withTitle = true ): Promise { const card = this.boardNodeFactory.buildCard(); @@ -154,14 +154,14 @@ export class CommonCartridgeImportService { private async createCard( parser: CommonCartridgeFileParser, column: Column, - cardProps: CommonCartridgeOrganizationProps, - organizations: CommonCartridgeOrganizationProps[] + cardProps: CommonCartridgeImportOrganizationProps, + organizations: CommonCartridgeImportOrganizationProps[] ) { const card = this.boardNodeFactory.buildCard(); const { title, height } = this.mapper.mapOrganizationToCard(cardProps, true); card.title = title; card.height = height; - await this.boardNodeService.addToParent(column, column); + await this.boardNodeService.addToParent(column, card); const cardElements = organizations.filter( (organization) => organization.pathDepth >= 3 && organization.path.startsWith(cardProps.path) @@ -175,7 +175,7 @@ export class CommonCartridgeImportService { private async createCardElement( parser: CommonCartridgeFileParser, card: Card, - cardElementProps: CommonCartridgeOrganizationProps + cardElementProps: CommonCartridgeImportOrganizationProps ) { if (cardElementProps.isResource) { const resource = parser.getResource(cardElementProps); diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts index cbd8bee44d6..8d5242de5e2 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts @@ -4,7 +4,8 @@ import { LessonCopyService } from '@modules/lesson/service'; import { ToolContextType } from '@modules/tool/common/enum'; import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; -import { ToolFeatures } from '@modules/tool/tool-config'; +import { ToolConfig } from '@modules/tool/tool-config'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { Course } from '@shared/domain/entity'; import { CourseRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; @@ -16,7 +17,6 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { IToolFeatures } from '@src/modules/tool/tool-config'; import { contextExternalToolFactory } from '../../tool/context-external-tool/testing'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; @@ -33,7 +33,7 @@ describe('course copy service', () => { let copyHelperService: DeepMocked; let userRepo: DeepMocked; let contextExternalToolService: DeepMocked; - let toolFeatures: IToolFeatures; + let configService: DeepMocked>; afterAll(async () => { await module.close(); @@ -81,10 +81,8 @@ describe('course copy service', () => { useValue: createMock(), }, { - provide: ToolFeatures, - useValue: { - ctlToolsTabEnabled: false, - }, + provide: ConfigService, + useValue: createMock>(), }, ], }).compile(); @@ -98,7 +96,7 @@ describe('course copy service', () => { copyHelperService = module.get(CopyHelperService); userRepo = module.get(UserRepo); contextExternalToolService = module.get(ContextExternalToolService); - toolFeatures = module.get(ToolFeatures); + configService = module.get(ConfigService); }); beforeEach(() => { @@ -137,7 +135,7 @@ describe('course copy service', () => { lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(boardCopyStatus); - toolFeatures.ctlToolsCopyEnabled = true; + configService.get.mockReturnValue(true); return { user, @@ -384,7 +382,7 @@ describe('course copy service', () => { lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(boardCopyStatus); - toolFeatures.ctlToolsCopyEnabled = false; + configService.get.mockReturnValue(false); return { user, diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index e176ef3ea37..bf425d0c376 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -2,11 +2,12 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from ' import { ToolContextType } from '@modules/tool/common/enum'; import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; -import { Inject, Injectable } from '@nestjs/common'; +import { ToolConfig } from '@modules/tool/tool-config'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Course, User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { LegacyBoardRepo, CourseRepo, UserRepo } from '@shared/repo'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; +import { CourseRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; import { BoardCopyService } from './board-copy.service'; import { RoomsService } from './rooms.service'; @@ -19,7 +20,7 @@ type CourseCopyParams = { @Injectable() export class CourseCopyService { constructor( - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly configService: ConfigService, private readonly courseRepo: CourseRepo, private readonly legacyBoardRepo: LegacyBoardRepo, private readonly roomsService: RoomsService, @@ -52,7 +53,7 @@ export class CourseCopyService { // copy course and board const courseCopy = await this.copyCourseEntity({ user, originalCourse, copyName }); - if (this.toolFeatures.ctlToolsCopyEnabled) { + if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { const contextRef: ContextRef = { id: courseId, type: ToolContextType.COURSE }; const contextExternalToolsInContext: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext(contextRef); @@ -120,7 +121,7 @@ export class CourseCopyService { boardStatus, ]; - if (this.toolFeatures.ctlToolsCopyEnabled) { + if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { elements.push({ type: CopyElementType.EXTERNAL_TOOL, status: CopyStatusEnum.SUCCESS, diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts index eedae71a09f..fe50b3f5926 100644 --- a/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts @@ -1,12 +1,12 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MikroORM } from '@mikro-orm/core'; +import { AuthorizationService } from '@modules/authorization'; import { NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { courseFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationService } from '@src/modules/authorization'; import { LearnroomConfig } from '../learnroom.config'; import { CommonCartridgeImportService } from '../service'; import { CourseImportUc } from './course-import.uc'; diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.ts index 4eccabe8132..33b0e9a3a63 100644 --- a/apps/server/src/modules/learnroom/uc/course-import.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.ts @@ -1,8 +1,8 @@ +import { AuthorizationService } from '@modules/authorization'; import { Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { AuthorizationService } from '@src/modules/authorization'; import { LearnroomConfig } from '../learnroom.config'; import { CommonCartridgeImportService } from '../service'; diff --git a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts index 29b0a5be59b..3c2a082fd4a 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts @@ -1,12 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationService } from '@modules/authorization'; +import { RoleDto, RoleService } from '@modules/role'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { CourseRepo } from '@shared/repo'; import { courseFactory, setupEntities, UserAndAccountTestFactory } from '@shared/testing'; -import { AuthorizationService } from '@src/modules/authorization'; -import { RoleDto, RoleService } from '@src/modules/role'; -import { CourseUc } from './course.uc'; import { CourseService } from '../service'; +import { CourseUc } from './course.uc'; describe('CourseUc', () => { let module: TestingModule; diff --git a/apps/server/src/modules/learnroom/uc/course.uc.ts b/apps/server/src/modules/learnroom/uc/course.uc.ts index a60676803d9..ed7abf98a1d 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.ts @@ -1,11 +1,11 @@ +import { AuthorizationService } from '@modules/authorization'; +import { RoleService } from '@modules/role'; import { Injectable } from '@nestjs/common'; import { PaginationParams } from '@shared/controller/'; import { Course } from '@shared/domain/entity'; import { SortOrder } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; -import { AuthorizationService } from '@src/modules/authorization'; -import { RoleService } from '@src/modules/role'; import { RoleNameMapper } from '../mapper/rolename.mapper'; import { CourseService } from '../service'; diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts index ed3643eb9ae..45b209ae7dc 100644 --- a/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts @@ -2,7 +2,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity'; -import { TestApiClient, federalStateFactory } from '@shared/testing'; +import { TestApiClient, federalStateFactory, schoolYearFactory } from '@shared/testing'; import { AdminApiServerTestModule } from '@src/modules/server/admin-api.server.module'; import { AuthGuard } from '@nestjs/passport'; import { AdminApiSchoolCreateResponseDto } from '../dto/response/admin-api-school-create.response.dto'; @@ -55,8 +55,9 @@ describe('Admin API - Schools (API)', () => { describe('with api token', () => { const setup = async () => { const federalState = federalStateFactory.build({ name: 'niedersachsen' }); - await em.persistAndFlush(federalState); - return { federalState }; + const year = schoolYearFactory.build(); + await em.persistAndFlush([federalState, year]); + return { federalState, year }; }; it('should return school', async () => { diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts index af734110f96..42eebe94443 100644 --- a/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts @@ -1,17 +1,18 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; +import { SystemEntity } from '@modules/system/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { cleanupCollections, + schoolEntityFactory, schoolSystemOptionsEntityFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; -import { schoolEntityFactory } from '@shared/testing/factory/school-entity.factory'; import { SchoolSystemOptionsEntity } from '../../entity'; import { ProvisioningOptionsInterface } from '../../interface'; import { SchulConneXProvisioningOptionsResponse } from '../dto'; diff --git a/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts b/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts index d38ba325df5..b33e370cca3 100644 --- a/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts +++ b/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts @@ -1,7 +1,7 @@ import { Embedded, Entity, ManyToOne, Unique } from '@mikro-orm/core'; +import { SystemEntity } from '@modules/system/entity'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; -import { SystemEntity } from '@shared/domain/entity/system.entity'; import { EntityId } from '@shared/domain/types'; import { ProvisioningOptionsInterface } from '../interface'; import { ProvisioningOptionsEntity } from './provisioning-options.entity'; diff --git a/apps/server/src/modules/legacy-school/loggable/schoolyear-no-years-left.loggable.ts b/apps/server/src/modules/legacy-school/loggable/schoolyear-no-years-left.loggable.ts new file mode 100644 index 00000000000..a9dc93fe0f0 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/schoolyear-no-years-left.loggable.ts @@ -0,0 +1,15 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchoolYearsNoYearsLeft extends InternalServerErrorException implements Loggable { + // this is a 500, because our development team is responsible to create schoolyears. + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + /* istanbul ignore next */ + return { + type: 'SCHOOL_YEARS_NO_YEARS_LEFT', + message: + 'Could not find any schoolyears with an enddate later than the current date. Check if the next schoolyear has been created in the database.', + stack: this.stack, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts b/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts index 44ca21bb878..4172d65261d 100644 --- a/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts +++ b/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts @@ -1,5 +1,6 @@ import { EntityManager } from '@mikro-orm/mongodb'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SystemEntity } from '@modules/system/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsProps } from '../domain'; import { SchoolSystemOptionsEntity, SchoolSystemOptionsEntityProps } from '../entity'; diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts index a268d279bfb..618b56a4773 100644 --- a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts @@ -1,7 +1,8 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { schoolEntityFactory, diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts index 16c96463c74..3d083e7daec 100644 --- a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts +++ b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts @@ -1,6 +1,6 @@ import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { Injectable } from '@nestjs/common'; -import { SystemEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { EntityId } from '@shared/domain/types'; import { diff --git a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts index 241200e81c2..0d2f510f467 100644 --- a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts @@ -125,4 +125,104 @@ describe('schoolyear repo', () => { }); }); }); + + describe('findCurrentOrNextYear', () => { + describe('when current date is within a schoolyear', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2021-01-01')); + + const previousYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2019-09-01'), + endDate: new Date('2020-07-31'), + }); + + const currentYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2020-09-01'), + endDate: new Date('2021-07-31'), + }); + + const nextYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2021-09-01'), + endDate: new Date('2022-07-31'), + }); + + await em.persistAndFlush([previousYear, currentYear, nextYear]); + em.clear(); + + return { previousYear, currentYear, nextYear }; + }; + + it('should return the current schoolyear', async () => { + const { currentYear } = await setup(); + + const result = await repo.findCurrentOrNextYear(); + + expect(result).toEqual(currentYear); + }); + }); + + describe('when current date is during a summerbreak', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2020-08-31')); + + const previousYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2019-09-01'), + endDate: new Date('2020-07-31'), + }); + + const nextYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2020-09-01'), + endDate: new Date('2021-07-31'), + }); + + await em.persistAndFlush([previousYear, nextYear]); + em.clear(); + + return { previousYear, nextYear }; + }; + + it('should return the upcoming schoolyear', async () => { + const { nextYear } = await setup(); + + const result = await repo.findCurrentOrNextYear(); + + expect(result).toEqual(nextYear); + }); + }); + + describe('when current date is later than any year', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2030-08-31')); + + const previousYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2019-09-01'), + endDate: new Date('2020-07-31'), + }); + + const nextYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2020-09-01'), + endDate: new Date('2021-07-31'), + }); + + await em.persistAndFlush([previousYear, nextYear]); + em.clear(); + + return { previousYear, nextYear }; + }; + + it('should throw', async () => { + await setup(); + + const func = () => repo.findCurrentOrNextYear(); + + await expect(func).rejects.toThrow(); + }); + }); + }); }); diff --git a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.ts b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.ts index 14115f36002..635f929918f 100644 --- a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.ts +++ b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { SchoolYearEntity } from '@shared/domain/entity'; import { BaseRepo } from '@shared/repo/base.repo'; +import { SchoolYearsNoYearsLeft } from '../loggable/schoolyear-no-years-left.loggable'; @Injectable() export class SchoolYearRepo extends BaseRepo { @@ -15,4 +16,17 @@ export class SchoolYearRepo extends BaseRepo { }); return year; } + + async findCurrentOrNextYear(): Promise { + const currentDate = new Date(); + const years: SchoolYearEntity[] = await this._em.find( + SchoolYearEntity, + { endDate: { $gte: currentDate } }, + { orderBy: { endDate: 'ASC' } } + ); + if (years.length < 1) { + throw new SchoolYearsNoYearsLeft(); + } + return years[0]; + } } diff --git a/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts b/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts index c51a405eb52..67281d163cd 100644 --- a/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts @@ -3,10 +3,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SchoolFeature } from '@shared/domain/types'; import { LegacySchoolRepo } from '@shared/repo'; -import { federalStateFactory, legacySchoolDoFactory, setupEntities } from '@shared/testing'; +import { federalStateFactory, legacySchoolDoFactory, schoolYearFactory, setupEntities } from '@shared/testing'; import { FederalStateService } from './federal-state.service'; import { LegacySchoolService } from './legacy-school.service'; import { SchoolValidationService } from './validation/school-validation.service'; +import { SchoolYearService } from './school-year.service'; describe('LegacySchoolService', () => { let module: TestingModule; @@ -15,6 +16,7 @@ describe('LegacySchoolService', () => { let schoolRepo: DeepMocked; let schoolValidationService: DeepMocked; let federalStateService: DeepMocked; + let schoolYearService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -32,6 +34,10 @@ describe('LegacySchoolService', () => { provide: FederalStateService, useValue: createMock(), }, + { + provide: SchoolYearService, + useValue: createMock(), + }, ], }).compile(); @@ -39,6 +45,7 @@ describe('LegacySchoolService', () => { schoolService = module.get(LegacySchoolService); schoolValidationService = module.get(SchoolValidationService); federalStateService = module.get(FederalStateService); + schoolYearService = module.get(SchoolYearService); await setupEntities(); }); @@ -357,7 +364,9 @@ describe('LegacySchoolService', () => { const name = 'Hogwarts'; const federalStateName = 'maybescottland?'; const federalState = federalStateFactory.build({ name: federalStateName }); + const year = schoolYearFactory.build(); federalStateService.findFederalStateByName.mockResolvedValue(federalState); + schoolYearService.getCurrentOrNextSchoolYear.mockResolvedValue(year); const school = await schoolService.createSchool({ name, federalStateName }); expect(school.name).toEqual(name); @@ -368,7 +377,9 @@ describe('LegacySchoolService', () => { const name = 'Hogwarts'; const federalStateName = 'maybescottland?'; const federalState = federalStateFactory.build({ name: federalStateName }); + const year = schoolYearFactory.build(); federalStateService.findFederalStateByName.mockResolvedValue(federalState); + schoolYearService.getCurrentOrNextSchoolYear.mockResolvedValue(year); const school = await schoolService.createSchool({ name, federalStateName }); expect(schoolRepo.save).toHaveBeenCalledWith(school); diff --git a/apps/server/src/modules/legacy-school/service/legacy-school.service.ts b/apps/server/src/modules/legacy-school/service/legacy-school.service.ts index db39ba7159b..0f74e1feeaf 100644 --- a/apps/server/src/modules/legacy-school/service/legacy-school.service.ts +++ b/apps/server/src/modules/legacy-school/service/legacy-school.service.ts @@ -4,6 +4,7 @@ import { EntityId, SchoolFeature } from '@shared/domain/types'; import { LegacySchoolRepo } from '@shared/repo'; import { FederalStateService } from './federal-state.service'; import { SchoolValidationService } from './validation'; +import { SchoolYearService } from './school-year.service'; /** * @deprecated because it uses the deprecated LegacySchoolDo. @@ -13,7 +14,8 @@ export class LegacySchoolService { constructor( private readonly schoolRepo: LegacySchoolRepo, private readonly schoolValidationService: SchoolValidationService, - private readonly federalStateService: FederalStateService + private readonly federalStateService: FederalStateService, + private readonly schoolYearService: SchoolYearService ) {} async hasFeature(schoolId: EntityId, feature: SchoolFeature): Promise { @@ -59,7 +61,17 @@ export class LegacySchoolService { async createSchool(props: { name: string; federalStateName: string }): Promise { const federalState = await this.federalStateService.findFederalStateByName(props.federalStateName); - const school = new LegacySchoolDo({ name: props.name, federalState }); + const schoolYear = await this.schoolYearService.getCurrentOrNextSchoolYear(); + const defaults = { + // fileStorageType: 'awsS3', + schoolYear, + permissions: { + teacher: { + STUDENT_LIST: true, + }, + }, + }; + const school = new LegacySchoolDo({ ...defaults, name: props.name, federalState }); await this.schoolRepo.save(school); return school; } diff --git a/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts b/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts index 4a580159a79..e2ba8097c36 100644 --- a/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts @@ -57,6 +57,30 @@ describe('SchoolYearService', () => { }); }); + describe('getCurrentOrNextSchoolYear', () => { + const setup = () => { + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2021-09-01'), + endDate: new Date('2022-12-31'), + }); + schoolYearRepo.findCurrentOrNextYear.mockResolvedValue(schoolYear); + + return { + schoolYear, + }; + }; + + describe('when called', () => { + it('should return the current school year', async () => { + const { schoolYear } = setup(); + + const currentSchoolYear: SchoolYearEntity = await service.getCurrentOrNextSchoolYear(); + + expect(currentSchoolYear).toEqual(schoolYear); + }); + }); + }); + describe('findById', () => { const setup = () => { const schoolYear: SchoolYearEntity = schoolYearFactory.build({ diff --git a/apps/server/src/modules/legacy-school/service/school-year.service.ts b/apps/server/src/modules/legacy-school/service/school-year.service.ts index d9a70b7a841..e52e3c0dae9 100644 --- a/apps/server/src/modules/legacy-school/service/school-year.service.ts +++ b/apps/server/src/modules/legacy-school/service/school-year.service.ts @@ -14,6 +14,12 @@ export class SchoolYearService { return current; } + async getCurrentOrNextSchoolYear(): Promise { + const current: SchoolYearEntity = await this.schoolYearRepo.findCurrentOrNextYear(); + + return current; + } + async findById(id: EntityId): Promise { const year: SchoolYearEntity = await this.schoolYearRepo.findById(id); diff --git a/apps/server/src/modules/management/seed-data/schools.ts b/apps/server/src/modules/management/seed-data/schools.ts index 42f74aa90ac..3a6e6ce4bb6 100644 --- a/apps/server/src/modules/management/seed-data/schools.ts +++ b/apps/server/src/modules/management/seed-data/schools.ts @@ -1,16 +1,11 @@ /* eslint-disable @typescript-eslint/dot-notation */ -import { - FederalStateEntity, - SchoolProperties, - SchoolRoles, - SchoolYearEntity, - SystemEntity, -} from '@shared/domain/entity'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; +import { FederalStateEntity, SchoolProperties, SchoolRoles, SchoolYearEntity } from '@shared/domain/entity'; import { LanguageType } from '@shared/domain/interface'; import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; import { federalStateFactory, schoolEntityFactory } from '@shared/testing'; import { FileStorageType } from '@src/modules/school/domain/type/file-storage-type.enum'; -import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; import { EFederalState } from './federalstates'; import { SeedSchoolYearEnum } from './schoolyears'; diff --git a/apps/server/src/modules/management/seed-data/systems.ts b/apps/server/src/modules/management/seed-data/systems.ts index 15b4e6a9306..1a9c531d870 100644 --- a/apps/server/src/modules/management/seed-data/systems.ts +++ b/apps/server/src/modules/management/seed-data/systems.ts @@ -1,5 +1,5 @@ /* eslint-disable no-template-curly-in-string */ -import { SystemEntityProps } from '@shared/domain/entity'; +import { SystemEntityProps } from '@modules/system/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { systemEntityFactory } from '@shared/testing'; import { DeepPartial } from 'fishery'; diff --git a/apps/server/src/modules/management/uc/database-management.uc.spec.ts b/apps/server/src/modules/management/uc/database-management.uc.spec.ts index 01f4b6955b5..3c627a6ff7e 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.spec.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.spec.ts @@ -4,9 +4,10 @@ import { DatabaseManagementService } from '@infra/database'; import { DefaultEncryptionService, LdapEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { FileSystemAdapter } from '@infra/file-system'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { StorageProviderEntity, SystemEntity } from '@shared/domain/entity'; +import { StorageProviderEntity } from '@shared/domain/entity'; import { setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { BsonConverter } from '../converter/bson.converter'; diff --git a/apps/server/src/modules/management/uc/database-management.uc.ts b/apps/server/src/modules/management/uc/database-management.uc.ts index 8ec0dfcb436..e9422066b67 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.ts @@ -2,13 +2,14 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { DatabaseManagementService } from '@infra/database'; import { DefaultEncryptionService, EncryptionService, LdapEncryptionService } from '@infra/encryption'; import { FileSystemAdapter } from '@infra/file-system'; +import { UmzugMigration } from '@mikro-orm/migrations-mongodb'; import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { StorageProviderEntity, SystemEntity } from '@shared/domain/entity'; +import { StorageProviderEntity } from '@shared/domain/entity'; import { LegacyLogger } from '@src/core/logger'; import { orderBy } from 'lodash'; -import { UmzugMigration } from '@mikro-orm/migrations-mongodb'; import { BsonConverter } from '../converter/bson.converter'; import { generateSeedData } from '../seed-data/generateSeedData'; diff --git a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts index 35a1b9ece82..cc9b95bdabf 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts @@ -2,7 +2,6 @@ import { OauthProviderServiceModule } from '@infra/oauth-provider'; import { LtiToolModule } from '@modules/lti-tool'; import { PseudonymModule } from '@modules/pseudonym'; import { ToolModule } from '@modules/tool'; -import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { TeamsRepo } from '@shared/repo'; @@ -11,15 +10,7 @@ import { IdTokenService } from './service/id-token.service'; import { OauthProviderLoginFlowService } from './service/oauth-provider.login-flow.service'; @Module({ - imports: [ - OauthProviderServiceModule, - UserModule, - LoggerModule, - PseudonymModule, - LtiToolModule, - ToolModule, - ToolConfigModule, - ], + imports: [OauthProviderServiceModule, UserModule, LoggerModule, PseudonymModule, LtiToolModule, ToolModule], providers: [OauthProviderLoginFlowService, IdTokenService, TeamsRepo], exports: [OauthProviderLoginFlowService, IdTokenService], }) diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts index df118628596..fb308ebe962 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts @@ -3,20 +3,21 @@ import { LtiToolService } from '@modules/lti-tool'; import { ExternalTool } from '@modules/tool/external-tool/domain'; import { ExternalToolService } from '@modules/tool/external-tool/service'; import { externalToolFactory } from '@modules/tool/external-tool/testing'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; +import { ToolConfig } from '@modules/tool/tool-config'; import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiToolDO } from '@shared/domain/domainobject'; import { ltiToolDOFactory, setupEntities } from '@shared/testing'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; -describe('OauthProviderLoginFlowService', () => { +describe(OauthProviderLoginFlowService.name, () => { let module: TestingModule; let service: OauthProviderLoginFlowService; let ltiToolService: DeepMocked; let externalToolService: DeepMocked; - let toolFeatures: IToolFeatures; + let configService: DeepMocked>; beforeAll(async () => { module = await Test.createTestingModule({ @@ -31,10 +32,8 @@ describe('OauthProviderLoginFlowService', () => { useValue: createMock(), }, { - provide: ToolFeatures, - useValue: { - ctlToolsTabEnabled: false, - }, + provide: ConfigService, + useValue: createMock>(), }, ], }).compile(); @@ -42,7 +41,7 @@ describe('OauthProviderLoginFlowService', () => { service = module.get(OauthProviderLoginFlowService); ltiToolService = module.get(LtiToolService); externalToolService = module.get(ExternalToolService); - toolFeatures = module.get(ToolFeatures); + configService = module.get(ConfigService); await setupEntities(); }); @@ -58,7 +57,7 @@ describe('OauthProviderLoginFlowService', () => { describe('findToolByClientId', () => { describe('when it finds a ctl tool and the ctl feature is active', () => { const setup = () => { - toolFeatures.ctlToolsTabEnabled = true; + configService.get.mockReturnValue(true); const tool: ExternalTool = externalToolFactory.buildWithId({ name: 'SchulcloudNextcloud' }); @@ -81,7 +80,7 @@ describe('OauthProviderLoginFlowService', () => { describe('when a lti tool exists and the ctl feature is deactivated', () => { const setup = () => { - toolFeatures.ctlToolsTabEnabled = false; + configService.get.mockReturnValue(false); const tool: LtiToolDO = ltiToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); @@ -111,7 +110,7 @@ describe('OauthProviderLoginFlowService', () => { describe('when a lti tool exists and the ctl feature is active and no ctl tool exists', () => { const setup = () => { - toolFeatures.ctlToolsTabEnabled = true; + configService.get.mockReturnValue(true); const tool: LtiToolDO = ltiToolDOFactory.buildWithId({ name: 'SchulcloudNextcloud' }); @@ -142,7 +141,7 @@ describe('OauthProviderLoginFlowService', () => { describe('when no lti or ctl tool was found', () => { const setup = () => { - toolFeatures.ctlToolsTabEnabled = true; + configService.get.mockReturnValue(true); externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(null); ltiToolService.findByClientIdAndIsLocal.mockResolvedValue(null); diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts index adf363415fd..75ed2aaa891 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts +++ b/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts @@ -1,10 +1,10 @@ import { LtiToolService } from '@modules/lti-tool/service'; import { ExternalTool } from '@modules/tool/external-tool/domain'; import { ExternalToolService } from '@modules/tool/external-tool/service'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; -import { Inject } from '@nestjs/common'; +import { ToolConfig } from '@modules/tool/tool-config'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; +import { ConfigService } from '@nestjs/config'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; @Injectable() @@ -12,11 +12,11 @@ export class OauthProviderLoginFlowService { constructor( private readonly ltiToolService: LtiToolService, private readonly externalToolService: ExternalToolService, - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures + private readonly configService: ConfigService ) {} public async findToolByClientId(clientId: string): Promise { - if (this.toolFeatures.ctlToolsTabEnabled) { + if (this.configService.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { const externalTool: ExternalTool | null = await this.externalToolService.findExternalToolByOAuth2ConfigClientId( clientId ); diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts index 3ea58e99d72..cd6d68b77f0 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -8,6 +8,9 @@ import { HydraOauthUc } from '../uc'; import { AuthorizationParams } from './dto'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; +/** + * @deprecated To be removed in N21-2071 + */ @ApiTags('SSO') @Controller('sso') export class OauthSSOController { 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 9874bbb22da..450529bd1ae 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.spec.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.spec.ts @@ -5,11 +5,12 @@ 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 { OauthConfigEntity } from '@modules/system/entity'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { LtiPrivacyPermission, LtiRoleType, OauthConfigEntity } from '@shared/domain/entity'; +import { LtiPrivacyPermission, LtiRoleType } from '@shared/domain/entity'; import { LtiToolRepo } from '@shared/repo'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/oauth/service/hydra.service.ts b/apps/server/src/modules/oauth/service/hydra.service.ts index ca439858e9f..04e93b1a2d7 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.ts @@ -3,18 +3,21 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { AuthorizationParams } from '@modules/oauth/controller/dto/authorization.params'; import { CookiesDto } from '@modules/oauth/service/dto/cookies.dto'; import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; +import { OauthConfigEntity } from '@modules/system/entity'; import { HttpService } from '@nestjs/axios'; import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { OauthConfigEntity } from '@shared/domain/entity'; import { LtiToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { nanoid } from 'nanoid'; import QueryString from 'qs'; -import { Observable, firstValueFrom } from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; +/** + * @deprecated To be removed in N21-2071 + */ @Injectable() export class HydraSsoService { constructor( 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 de9246db5e7..b31f8c38c99 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -4,19 +4,18 @@ import { DefaultEncryptionService, EncryptionService, SymetricKeyEncryptionServi import { ObjectId } from '@mikro-orm/mongodb'; 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 { OauthConfigEntity } from '@modules/system/entity'; +import { SystemService } from '@modules/system/service'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SchoolFeature } from '@shared/domain/types'; -import { legacySchoolDoFactory, setupEntities, systemEntityFactory, userDoFactory } from '@shared/testing'; +import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { OauthDataDto } from '@src/modules/provisioning/dto'; -import { LegacySystemService } from '@src/modules/system'; +import { System } from '@src/modules/system'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { @@ -49,12 +48,12 @@ 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 testSystem: System; let testOauthConfig: OauthConfigEntity; const hostUri = 'https://mock.de'; @@ -86,8 +85,8 @@ describe('OAuthService', () => { useValue: createMock(), }, { - provide: LegacySystemService, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: OauthAdapterService, @@ -104,7 +103,7 @@ describe('OAuthService', () => { oAuthEncryptionService = module.get(DefaultEncryptionService); provisioningService = module.get(ProvisioningService); userService = module.get(UserService); - systemService = module.get(LegacySystemService); + systemService = module.get(SystemService); oauthAdapterService = module.get(OauthAdapterService); migrationCheckService = module.get(MigrationCheckService); schoolService = module.get(LegacySchoolService); @@ -129,7 +128,7 @@ describe('OAuthService', () => { } }); - testSystem = systemEntityFactory.withOauthConfig().buildWithId(); + testSystem = systemFactory.withOauthConfig().build(); testOauthConfig = testSystem.oauthConfig as OauthConfigEntity; }); @@ -200,24 +199,12 @@ describe('OAuthService', () => { const setup = () => { const authCode = '43534543jnj543342jn2'; - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', + const system: System = systemFactory.withOauthConfig().build({ + displayName: 'External System', }); - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, + + const ldapSystem: System = systemFactory.withLdapConfig().build({ + displayName: 'External System', }); const oauthToken: OAuthTokenDto = { @@ -230,7 +217,7 @@ describe('OAuthService', () => { authCode, system, oauthToken, - oauthConfig, + ldapSystem, }; }; @@ -242,7 +229,7 @@ describe('OAuthService', () => { oauthAdapterService.getPublicKey.mockResolvedValue('publicKey'); oauthAdapterService.sendTokenRequest.mockResolvedValue(oauthToken); - const result: OAuthTokenDto = await service.authenticateUser(system.id!, 'redirectUri', authCode); + const result: OAuthTokenDto = await service.authenticateUser(system.id, 'redirectUri', authCode); expect(result).toEqual({ accessToken: oauthToken.accessToken, @@ -254,10 +241,9 @@ describe('OAuthService', () => { describe('when system does not have oauth config', () => { it('the authentication should fail', async () => { - const { authCode, system } = setup(); - system.oauthConfig = undefined; + const { authCode, ldapSystem } = setup(); - systemService.findById.mockResolvedValueOnce(system); + systemService.findById.mockResolvedValueOnce(ldapSystem); const func = () => service.authenticateUser(testSystem.id, 'redirectUri', authCode); diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index ebba4c64e66..4b8eb53afb8 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -2,14 +2,13 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { LegacySchoolService } from '@modules/legacy-school'; import { OauthDataDto } from '@modules/provisioning/dto/oauth-data.dto'; import { ProvisioningService } from '@modules/provisioning/service/provisioning.service'; -import { LegacySystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service'; +import { System, SystemService } from '@modules/system'; +import { OauthConfigEntity } from '@modules/system/entity'; 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 { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { OauthConfigEntity } from '@shared/domain/entity'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; @@ -31,7 +30,7 @@ export class OAuthService { @Inject(DefaultEncryptionService) private readonly oAuthEncryptionService: EncryptionService, private readonly logger: LegacyLogger, private readonly provisioningService: ProvisioningService, - private readonly systemService: LegacySystemService, + private readonly systemService: SystemService, private readonly migrationCheckService: MigrationCheckService, private readonly schoolService: LegacySchoolService ) { @@ -39,9 +38,9 @@ export class OAuthService { } async authenticateUser(systemId: string, redirectUri: string, code: string): Promise { - const system: SystemDto = await this.systemService.findById(systemId); + const system: System | null = await this.systemService.findById(systemId); - if (!system.oauthConfig) { + if (!system || !system.oauthConfig) { throw new OauthConfigMissingLoggableException(systemId); } const { oauthConfig } = system; 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 efaf7190329..269c69753e9 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 @@ -2,10 +2,10 @@ 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 { OauthConfigEntity } from '@modules/system/entity'; import { HttpModule } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfigEntity } from '@shared/domain/entity'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; 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 7b92362cd57..596aaf2e475 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -1,13 +1,16 @@ -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; +import { OauthConfigEntity } from '@modules/system/entity'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { OauthConfigEntity } from '@shared/domain/entity'; import { LegacyLogger } from '@src/core/logger'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; import { OAuthTokenDto } from '../interface'; import { AuthCodeFailureLoggableException } from '../loggable'; import { HydraSsoService, OAuthService } from '../service'; +import { HydraRedirectDto } from '../service/dto'; +/** + * @deprecated To be removed in N21-2071 + */ @Injectable() export class HydraOauthUc { constructor( diff --git a/apps/server/src/modules/provisioning/config/index.ts b/apps/server/src/modules/provisioning/config/index.ts deleted file mode 100644 index dbbb1de579b..00000000000 --- a/apps/server/src/modules/provisioning/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProvisioningFeatures, ProvisioningConfiguration, IProvisioningFeatures } from './provisioning-config'; diff --git a/apps/server/src/modules/provisioning/config/provisioning-config.ts b/apps/server/src/modules/provisioning/config/provisioning-config.ts deleted file mode 100644 index 1fa9b078d66..00000000000 --- a/apps/server/src/modules/provisioning/config/provisioning-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; - -export const ProvisioningFeatures = Symbol('ProvisioningFeatures'); - -export interface IProvisioningFeatures { - schulconnexGroupProvisioningEnabled: boolean; - schulconnexCourseSyncEnabled: boolean; - schulconnexOtherGroupusersEnabled: boolean; -} - -export class ProvisioningConfiguration { - static provisioningFeatures: IProvisioningFeatures = { - schulconnexGroupProvisioningEnabled: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, - schulconnexCourseSyncEnabled: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, - schulconnexOtherGroupusersEnabled: Configuration.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED') as boolean, - }; -} diff --git a/apps/server/src/modules/provisioning/domain/error/index.ts b/apps/server/src/modules/provisioning/domain/error/index.ts new file mode 100644 index 00000000000..ce58e5b2fdf --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/error/index.ts @@ -0,0 +1,2 @@ +export { InvalidLernperiodeResponseLoggableException } from './invalid-lernperiode-response.loggable-exception'; +export { InvalidLaufzeitResponseLoggableException } from './invalid-laufzeit-response.loggable-exception'; diff --git a/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.spec.ts b/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.spec.ts new file mode 100644 index 00000000000..f8f29727c09 --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { SchulconnexLaufzeitResponse } from '@infra/schulconnex-client'; +import { InvalidLaufzeitResponseLoggableException } from './invalid-laufzeit-response.loggable-exception'; + +describe(InvalidLaufzeitResponseLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const laufzeit = new SchulconnexLaufzeitResponse(); + + const exception = new InvalidLaufzeitResponseLoggableException(laufzeit); + + return { + exception, + laufzeit, + }; + }; + + it('should return the correct log message', () => { + const { exception, laufzeit } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_LAUFZEIT_RESPONSE', + stack: expect.any(String), + data: { + laufzeit, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.ts b/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.ts new file mode 100644 index 00000000000..bb46677224e --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/error/invalid-laufzeit-response.loggable-exception.ts @@ -0,0 +1,19 @@ +import { SchulconnexLaufzeitResponse } from '@infra/schulconnex-client'; +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class InvalidLaufzeitResponseLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly laufzeit: SchulconnexLaufzeitResponse) { + super(); + } + + getLogMessage(): ErrorLogMessage { + return { + type: 'INVALID_LAUFZEIT_RESPONSE', + stack: this.stack, + data: { + laufzeit: { ...this.laufzeit }, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.spec.ts b/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.spec.ts new file mode 100644 index 00000000000..43721f356b6 --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { InvalidLernperiodeResponseLoggableException } from './invalid-lernperiode-response.loggable-exception'; + +describe(InvalidLernperiodeResponseLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const lernperiode = '2024-223'; + + const exception = new InvalidLernperiodeResponseLoggableException(lernperiode); + + return { + exception, + lernperiode, + }; + }; + + it('should return the correct log message', () => { + const { exception, lernperiode } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_LERNPERIODE_RESPONSE', + stack: expect.any(String), + data: { + lernperiode, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.ts b/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.ts new file mode 100644 index 00000000000..6138b24c7f1 --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/error/invalid-lernperiode-response.loggable-exception.ts @@ -0,0 +1,18 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class InvalidLernperiodeResponseLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly lernperiode: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + return { + type: 'INVALID_LERNPERIODE_RESPONSE', + stack: this.stack, + data: { + lernperiode: this.lernperiode, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/domain/index.ts b/apps/server/src/modules/provisioning/domain/index.ts new file mode 100644 index 00000000000..93ae819eac1 --- /dev/null +++ b/apps/server/src/modules/provisioning/domain/index.ts @@ -0,0 +1 @@ +export * from './error'; diff --git a/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.spec.ts deleted file mode 100644 index 87833c6967c..00000000000 --- a/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { EmailAlreadyExistsLoggable } from '@modules/provisioning/loggable/email-already-exists.loggable'; - -describe('EmailAlreadyExistsLoggableException', () => { - describe('getLogMessage', () => { - const setup = () => { - const email = 'mock-email'; - const externalId = '789'; - - const loggable = new EmailAlreadyExistsLoggable(email, externalId); - - return { - loggable, - email, - externalId, - }; - }; - - it('should return the correct log message', () => { - const { loggable, email, externalId } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'The Email to be provisioned already exists.', - data: { - email, - externalId, - }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.ts b/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.ts deleted file mode 100644 index 1e4cd7ed77d..00000000000 --- a/apps/server/src/modules/provisioning/loggable/email-already-exists.loggable.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class EmailAlreadyExistsLoggable implements Loggable { - constructor(private readonly email: string, private readonly externalId?: string) {} - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: 'The Email to be provisioned already exists.', - data: { - email: this.email, - externalId: this.externalId, - }, - }; - } -} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index 48efe5c4f67..89b4baf9b7a 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -1,5 +1,4 @@ export * from './user-for-group-not-found.loggable'; export * from './school-for-group-not-found.loggable'; export * from './group-role-unknown.loggable'; -export { EmailAlreadyExistsLoggable } from './email-already-exists.loggable'; export { SchoolExternalToolCreatedLoggable } from './school-external-tool-created.loggable'; diff --git a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts index f25054ce1ac..7de363b3ef1 100644 --- a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts @@ -1,34 +1,36 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { systemFactory } from '@shared/testing'; import { ProvisioningSystemDto } from '../dto'; import { ProvisioningSystemInputMapper } from './provisioning-system-input.mapper'; describe('SchoolUcMapper', () => { describe('mapToInternal', () => { it('should map provisioningStrategy', () => { - const dto: SystemDto = new SystemDto({ - id: 'systemId', - type: '', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - provisioningUrl: 'provisioningUrl', + const provisioningStrategy = SystemProvisioningStrategy.SANIS; + const system = systemFactory.build({ + provisioningStrategy, + provisioningUrl: 'https://prov.url', }); - const result: ProvisioningSystemDto = ProvisioningSystemInputMapper.mapToInternal(dto); + const result: ProvisioningSystemDto = ProvisioningSystemInputMapper.mapToInternal(system); expect(result).toEqual({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - provisioningUrl: 'provisioningUrl', + systemId: system.id, + provisioningStrategy, + provisioningUrl: system.provisioningUrl, }); }); it('should map provisioningStrategy, when input undefined', () => { - const dto: SystemDto = new SystemDto({ type: '' }); + const system = systemFactory.build({ + provisioningStrategy: undefined, + provisioningUrl: undefined, + }); - const result: ProvisioningSystemDto = ProvisioningSystemInputMapper.mapToInternal(dto); + const result: ProvisioningSystemDto = ProvisioningSystemInputMapper.mapToInternal(system); expect(result).toEqual({ - systemId: '', + systemId: system.id, provisioningStrategy: SystemProvisioningStrategy.UNDEFINED, provisioningUrl: undefined, }); diff --git a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.ts b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.ts index 9668bbfffea..ca693762dca 100644 --- a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.ts +++ b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.ts @@ -1,13 +1,13 @@ +import { System } from '@modules/system'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; import { ProvisioningSystemDto } from '../dto'; export class ProvisioningSystemInputMapper { - static mapToInternal(dto: SystemDto) { + static mapToInternal(dto: System): ProvisioningSystemDto { return new ProvisioningSystemDto({ - systemId: dto.id || '', + systemId: dto.id, provisioningStrategy: dto.provisioningStrategy || SystemProvisioningStrategy.UNDEFINED, - provisioningUrl: dto.provisioningUrl || undefined, + provisioningUrl: dto.provisioningUrl, }); } } diff --git a/apps/server/src/modules/provisioning/provisioning-config.module.ts b/apps/server/src/modules/provisioning/provisioning-config.module.ts deleted file mode 100644 index 2e1aad944e9..00000000000 --- a/apps/server/src/modules/provisioning/provisioning-config.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ProvisioningConfiguration, ProvisioningFeatures } from './config'; - -@Module({ - providers: [ - { - provide: ProvisioningFeatures, - useValue: ProvisioningConfiguration.provisioningFeatures, - }, - ], - exports: [ProvisioningFeatures], -}) -export class ProvisioningConfigModule {} diff --git a/apps/server/src/modules/provisioning/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts index 45586873b17..4d7906e59c6 100644 --- a/apps/server/src/modules/provisioning/provisioning.config.ts +++ b/apps/server/src/modules/provisioning/provisioning.config.ts @@ -2,4 +2,6 @@ export interface ProvisioningConfig { FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED: boolean; PROVISIONING_SCHULCONNEX_LIZENZ_INFO_URL: string; + FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: boolean; + FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; } diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index 152636a3c1c..6d65266a0a6 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -11,7 +11,6 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { SchulconnexClientModule } from '@src/infra/schulconnex-client'; import { UserLicenseModule } from '../user-license'; -import { ProvisioningConfigModule } from './provisioning-config.module'; import { ProvisioningService } from './service/provisioning.service'; import { IservProvisioningStrategy, @@ -30,7 +29,6 @@ import { @Module({ imports: [ - ProvisioningConfigModule, AccountModule, LegacySchoolModule, UserModule, 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 1664c649b10..170ce718ef5 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 { System, SystemService } from '@modules/system'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { LegacySystemService } from '../../system/service/legacy-system.service'; +import { systemFactory } from '@shared/testing'; 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: LegacySystemService, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: SanisProvisioningStrategy, @@ -57,7 +57,7 @@ describe('ProvisioningService', () => { }).compile(); service = module.get(ProvisioningService); - systemService = module.get(LegacySystemService); + systemService = module.get(SystemService); provisioningStrategy = module.get(SanisProvisioningStrategy); }); @@ -70,16 +70,13 @@ describe('ProvisioningService', () => { }); const setupSystemData = () => { - const systemId = 'sanisSystemId'; - const system: SystemDto = new SystemDto({ - id: systemId, - type: 'sanis', - provisioningUrl: 'sanisUrl', + const system: System = systemFactory.withOauthConfig().build({ + provisioningUrl: 'https://api.moin.schule/', provisioningStrategy: SystemProvisioningStrategy.SANIS, }); const provisioningSystemDto: ProvisioningSystemDto = new ProvisioningSystemDto({ - systemId, - provisioningUrl: 'sanisUrl', + systemId: system.id, + provisioningUrl: 'https://api.moin.schule/', provisioningStrategy: SystemProvisioningStrategy.SANIS, }); const oauthDataDto: OauthDataDto = new OauthDataDto({ @@ -93,7 +90,6 @@ describe('ProvisioningService', () => { }); return { - systemId, system, provisioningSystemDto, oauthDataDto, @@ -103,17 +99,16 @@ describe('ProvisioningService', () => { describe('getData is called', () => { const setup = () => { - const { systemId, system, provisioningSystemDto, oauthDataDto } = setupSystemData(); + const { system, provisioningSystemDto, oauthDataDto } = setupSystemData(); const accessToken = 'accessToken'; const idToken = 'idToken'; - systemService.findById.mockResolvedValue(system); + systemService.findByIdOrFail.mockResolvedValue(system); provisioningStrategy.getData.mockResolvedValue(oauthDataDto); return { accessToken, idToken, - systemId, system, provisioningSystemDto, oauthDataDto, @@ -122,9 +117,9 @@ describe('ProvisioningService', () => { describe('when the provisioning strategy is found', () => { it('should call strategy.getData', async () => { - const { accessToken, idToken, systemId, provisioningSystemDto } = setup(); + const { accessToken, idToken, system, provisioningSystemDto } = setup(); - await service.getData(systemId, idToken, accessToken); + await service.getData(system.id, idToken, accessToken); expect(provisioningStrategy.getData).toHaveBeenCalledWith( new OauthDataStrategyInputDto({ @@ -136,9 +131,9 @@ describe('ProvisioningService', () => { }); it('should return the oauth data', async () => { - const { accessToken, idToken, systemId, oauthDataDto } = setup(); + const { accessToken, idToken, system, oauthDataDto } = setup(); - const result: OauthDataDto = await service.getData(systemId, idToken, accessToken); + const result: OauthDataDto = await service.getData(system.id, idToken, accessToken); expect(result).toEqual(oauthDataDto); }); @@ -147,12 +142,11 @@ describe('ProvisioningService', () => { describe('when no provisioning strategy is found', () => { it('should throw an InternalServerErrorException', async () => { const { accessToken, idToken } = setup(); - const systemWithoutStrategy: SystemDto = new SystemDto({ - type: '', + const systemWithoutStrategy: System = systemFactory.withOauthConfig().build({ provisioningStrategy: SystemProvisioningStrategy.UNDEFINED, }); - systemService.findById.mockResolvedValue(systemWithoutStrategy); + systemService.findByIdOrFail.mockResolvedValue(systemWithoutStrategy); const promise: Promise = service.getData('systemId', idToken, accessToken); diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.ts b/apps/server/src/modules/provisioning/service/provisioning.service.ts index 8f7330645b5..3aefef535a3 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.ts @@ -1,5 +1,4 @@ -import { LegacySystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { System, SystemService } from '@modules/system'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto, ProvisioningSystemDto } from '../dto'; @@ -19,7 +18,7 @@ export class ProvisioningService { >(); constructor( - private readonly systemService: LegacySystemService, + private readonly systemService: SystemService, private readonly sanisStrategy: SanisProvisioningStrategy, private readonly iservStrategy: IservProvisioningStrategy, private readonly oidcMockStrategy: OidcMockProvisioningStrategy @@ -48,8 +47,10 @@ export class ProvisioningService { } private async determineInput(systemId: string): Promise { - const systemDto: SystemDto = await this.systemService.findById(systemId); + const systemDto: System = await this.systemService.findByIdOrFail(systemId); + const inputDto: ProvisioningSystemDto = ProvisioningSystemInputMapper.mapToInternal(systemDto); + return inputDto; } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts index a74bf170322..30408619f57 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts @@ -14,7 +14,6 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, ExternalSchoolDto, @@ -50,7 +49,6 @@ describe(SchulconnexProvisioningStrategy.name, () => { let module: TestingModule; let strategy: TestSchulconnexStrategy; - let provisioningFeatures: IProvisioningFeatures; let schulconnexSchoolProvisioningService: DeepMocked; let schulconnexUserProvisioningService: DeepMocked; let schulconnexGroupProvisioningService: DeepMocked; @@ -60,14 +58,12 @@ describe(SchulconnexProvisioningStrategy.name, () => { let configService: DeepMocked>; let schulconnexToolProvisioningService: DeepMocked; + const config: Partial = {}; + beforeAll(async () => { module = await Test.createTestingModule({ providers: [ TestSchulconnexStrategy, - { - provide: ProvisioningFeatures, - useValue: {}, - }, { provide: SchulconnexSchoolProvisioningService, useValue: createMock(), @@ -97,14 +93,15 @@ describe(SchulconnexProvisioningStrategy.name, () => { useValue: createMock(), }, { - provide: ConfigService, - useValue: createMock>(), + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), + }, }, ], }).compile(); strategy = module.get(TestSchulconnexStrategy); - provisioningFeatures = module.get(ProvisioningFeatures); schulconnexSchoolProvisioningService = module.get(SchulconnexSchoolProvisioningService); schulconnexUserProvisioningService = module.get(SchulconnexUserProvisioningService); schulconnexGroupProvisioningService = module.get(SchulconnexGroupProvisioningService); @@ -116,10 +113,9 @@ describe(SchulconnexProvisioningStrategy.name, () => { }); beforeEach(() => { - Object.assign>(provisioningFeatures, { - schulconnexGroupProvisioningEnabled: false, - schulconnexCourseSyncEnabled: false, - }); + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = false; + config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = false; + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; }); afterAll(async () => { @@ -127,7 +123,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('apply is called', () => { @@ -246,7 +242,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when group data is provided and the feature is enabled', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; const externalUserId = 'externalUserId'; const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); @@ -306,7 +302,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when group data is provided, but the feature is disabled', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = false; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = false; const externalUserId = 'externalUserId'; const oauthData: OauthDataDto = new OauthDataDto({ @@ -350,7 +346,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when group data is not provided', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; const externalUserId = 'externalUserId'; const oauthData: OauthDataDto = new OauthDataDto({ @@ -391,8 +387,8 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when an existing group gets provisioned', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - provisioningFeatures.schulconnexCourseSyncEnabled = true; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; + config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = true; const externalUserId = 'externalUserId'; const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); @@ -441,8 +437,8 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when a new group is provisioned', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - provisioningFeatures.schulconnexCourseSyncEnabled = true; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; + config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = true; const externalUserId = 'externalUserId'; const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); @@ -486,8 +482,8 @@ describe(SchulconnexProvisioningStrategy.name, () => { describe('when a user was removed from a group', () => { const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - provisioningFeatures.schulconnexCourseSyncEnabled = true; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; + config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = true; const externalUserId = 'externalUserId'; const oauthData: OauthDataDto = new OauthDataDto({ diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts index e976afb7bc5..007b70319bc 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts @@ -1,8 +1,7 @@ import { Group, GroupService } from '@modules/group'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; import { ProvisioningConfig } from '../../provisioning.config'; import { ProvisioningStrategy } from '../base.strategy'; @@ -18,7 +17,6 @@ import { @Injectable() export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrategy { constructor( - @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, protected readonly schulconnexSchoolProvisioningService: SchulconnexSchoolProvisioningService, protected readonly schulconnexUserProvisioningService: SchulconnexUserProvisioningService, protected readonly schulconnexGroupProvisioningService: SchulconnexGroupProvisioningService, @@ -46,7 +44,7 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate school?.id ); - if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { + if (this.configService.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { await this.provisionGroups(data, school); } @@ -87,7 +85,7 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate data.system.systemId ); - if (this.provisioningFeatures.schulconnexCourseSyncEnabled && provisionedGroup) { + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') && provisionedGroup) { await this.schulconnexCourseSyncService.synchronizeCourseWithGroup( provisionedGroup, existingGroup ?? undefined @@ -108,7 +106,7 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate data.system.systemId ); - if (this.provisioningFeatures.schulconnexCourseSyncEnabled) { + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { const courseSyncPromises: Promise[] = removedFromGroups.map( async (removedFromGroup: Group): Promise => { await this.schulconnexCourseSyncService.synchronizeCourseWithGroup(removedFromGroup, removedFromGroup); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts index a662a5d5502..881822cdea6 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts @@ -88,6 +88,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: newGroup.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, studentIds: [studentId], teacherIds: [teacherId], }), @@ -126,6 +128,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: newGroup.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, studentIds: [], teacherIds: [], }), @@ -164,6 +168,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: course.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, studentIds: [], teacherIds: [], }), @@ -208,6 +214,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: course.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, studentIds: [], teacherIds: [teacherUserId], syncedWithGroup: undefined, diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts index bdc853f49a5..04049de68aa 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts @@ -22,6 +22,9 @@ export class SchulconnexCourseSyncService { course.name = newGroup.name; } + course.startDate = newGroup.validFrom; + course.untilDate = newGroup.validUntil; + const students: GroupUser[] = newGroup.users.filter( (user: GroupUser): boolean => user.roleId === studentRole.id ); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts index 9601b32ab9a..ed3a05aa0c1 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts @@ -147,14 +147,15 @@ describe(SchulconnexToolProvisioningService.name, () => { await service.provisionSchoolExternalTools(userId, schoolId, systemId); - expect(schoolExternalToolService.saveSchoolExternalTool).toHaveBeenCalledWith({ - props: { + expect(schoolExternalToolService.saveSchoolExternalTool).toHaveBeenCalledWith<[SchoolExternalTool]>( + new SchoolExternalTool({ id: expect.any(String), toolId: externalTool.id, + isDeactivated: false, schoolId, parameters: [], - }, - }); + }) + ); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts index 75505abe65f..c24eea36374 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts @@ -72,6 +72,7 @@ export class SchulconnexToolProvisioningService { id: new ObjectId().toHexString(), toolId: externalTool.id, schoolId, + isDeactivated: false, parameters: [], }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts index 08f3f70bb38..05e6fa3d3ad 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts @@ -22,7 +22,6 @@ describe(SchulconnexUserProvisioningService.name, () => { let userService: DeepMocked; let roleService: DeepMocked; let accountService: DeepMocked; - let logger: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -51,7 +50,6 @@ describe(SchulconnexUserProvisioningService.name, () => { userService = module.get(UserService); roleService = module.get(RoleService); accountService = module.get(AccountService); - logger = module.get(Logger); }); afterAll(async () => { @@ -140,27 +138,6 @@ describe(SchulconnexUserProvisioningService.name, () => { }); }); - it('should call user service to check uniqueness of email', async () => { - const { externalUser, schoolId, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(userService.isEmailUniqueForExternal).toHaveBeenCalledWith(externalUser.email, undefined); - }); - - it('should call the user service to save the user', async () => { - const { externalUser, schoolId, savedUser, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - userService.isEmailUniqueForExternal.mockResolvedValue(true); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(userService.save).toHaveBeenCalledWith(new UserDO({ ...savedUser, id: undefined })); - }); - it('should return the saved user', async () => { const { externalUser, schoolId, savedUser, systemId } = setupUser(); @@ -198,35 +175,9 @@ describe(SchulconnexUserProvisioningService.name, () => { await expect(promise).rejects.toThrow(UnprocessableEntityException); }); }); - - describe('when the external user has an email, that already exists', () => { - it('should log EmailAlreadyExistsLoggable', async () => { - const { externalUser, systemId, schoolId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - userService.isEmailUniqueForExternal.mockResolvedValue(false); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(logger.warning).toHaveBeenCalledWith({ - email: externalUser.email, - }); - }); - }); }); describe('when the user already exists', () => { - it('should call user service to check uniqueness of email', async () => { - const { externalUser, schoolId, systemId, existingUser } = setupUser(); - - userService.findByExternalId.mockResolvedValue(existingUser); - userService.isEmailUniqueForExternal.mockResolvedValue(true); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(userService.isEmailUniqueForExternal).toHaveBeenCalledWith(externalUser.email, existingUser.externalId); - }); - it('should call the user service to save the user', async () => { const { externalUser, schoolId, existingUser, systemId } = setupUser(); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts index f451486e0a7..783076572de 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts @@ -1,12 +1,10 @@ import { AccountSave, AccountService } from '@modules/account'; -import { EmailAlreadyExistsLoggable } from '@modules/provisioning/loggable'; import { RoleDto, RoleService } from '@modules/role'; import { UserService } from '@modules/user'; import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RoleReference, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Logger } from '@src/core/logger'; import CryptoJS from 'crypto-js'; import { ExternalUserDto } from '../../../dto'; @@ -15,8 +13,7 @@ export class SchulconnexUserProvisioningService { constructor( private readonly userService: UserService, private readonly roleService: RoleService, - private readonly accountService: AccountService, - private readonly logger: Logger + private readonly accountService: AccountService ) {} public async provisionExternalUser( @@ -26,14 +23,12 @@ export class SchulconnexUserProvisioningService { ): Promise { const foundUser: UserDO | null = await this.userService.findByExternalId(externalUser.externalId, systemId); - const isEmailUnique: boolean = await this.checkUniqueEmail(externalUser.email, foundUser?.externalId); - const roleRefs: RoleReference[] | undefined = await this.createRoleReferences(externalUser.roles); let createNewAccount = false; let user: UserDO; if (foundUser) { - user = this.updateUser(externalUser, foundUser, isEmailUnique, roleRefs, schoolId); + user = this.updateUser(externalUser, foundUser, roleRefs, schoolId); } else { if (!schoolId) { throw new UnprocessableEntityException( @@ -42,7 +37,7 @@ export class SchulconnexUserProvisioningService { } createNewAccount = true; - user = this.createUser(externalUser, isEmailUnique, schoolId, roleRefs); + user = this.createUser(externalUser, schoolId, roleRefs); } const savedUser: UserDO = await this.userService.save(user); @@ -59,20 +54,6 @@ export class SchulconnexUserProvisioningService { return savedUser; } - private async checkUniqueEmail(email?: string, externalId?: string): Promise { - if (email) { - const isEmailUnique: boolean = await this.userService.isEmailUniqueForExternal(email, externalId); - - if (!isEmailUnique) { - this.logger.warning(new EmailAlreadyExistsLoggable(email, externalId)); - } - - return isEmailUnique; - } - - return true; - } - private async createRoleReferences(roles?: RoleName[]): Promise { if (roles) { const foundRoles: RoleDto[] = await this.roleService.findByNames(roles); @@ -89,14 +70,13 @@ export class SchulconnexUserProvisioningService { private updateUser( externalUser: ExternalUserDto, foundUser: UserDO, - isEmailUnique: boolean, roleRefs?: RoleReference[], schoolId?: string ): UserDO { const user: UserDO = foundUser; user.firstName = externalUser.firstName ?? foundUser.firstName; user.lastName = externalUser.lastName ?? foundUser.lastName; - user.email = isEmailUnique ? externalUser.email ?? foundUser.email : foundUser.email; + user.email = externalUser.email ?? foundUser.email; user.roles = roleRefs ?? foundUser.roles; user.schoolId = schoolId ?? foundUser.schoolId; user.birthday = externalUser.birthday ?? foundUser.birthday; @@ -104,17 +84,12 @@ export class SchulconnexUserProvisioningService { return user; } - private createUser( - externalUser: ExternalUserDto, - isEmailUnique: boolean, - schoolId: string, - roleRefs?: RoleReference[] - ): UserDO { + private createUser(externalUser: ExternalUserDto, schoolId: string, roleRefs?: RoleReference[]): UserDO { const user: UserDO = new UserDO({ externalId: externalUser.externalId, firstName: externalUser.firstName ?? '', lastName: externalUser.lastName ?? '', - email: isEmailUnique ? externalUser.email ?? '' : '', + email: externalUser.email ?? '', roles: roleRefs ?? [], schoolId, birthday: externalUser.birthday, diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts index 5b380116894..58d54a44495 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts @@ -17,7 +17,6 @@ import { ValidationErrorLoggableException } from '@shared/common/loggable-except import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import * as classValidator from 'class-validator'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, ExternalLicenseDto, @@ -52,9 +51,8 @@ describe(SanisProvisioningStrategy.name, () => { ArgsType >; - let provisioningFeatures: IProvisioningFeatures; - let configService: DeepMocked>; let schulconnexRestClient: DeepMocked; + const config: Partial = {}; beforeAll(async () => { module = await Test.createTestingModule({ @@ -92,13 +90,11 @@ describe(SanisProvisioningStrategy.name, () => { provide: SchulconnexToolProvisioningService, useValue: createMock(), }, - { - provide: ProvisioningFeatures, - useValue: {}, - }, { provide: ConfigService, - useValue: createMock>(), + useValue: { + get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), + }, }, { provide: SchulconnexRestClient, @@ -109,21 +105,16 @@ describe(SanisProvisioningStrategy.name, () => { strategy = module.get(SanisProvisioningStrategy); mapper = module.get(SchulconnexResponseMapper); - provisioningFeatures = module.get(ProvisioningFeatures); schulconnexRestClient = module.get(SchulconnexRestClient); - configService = module.get(ConfigService); validationFunction = jest.spyOn(classValidator, 'validate'); }); - beforeEach(() => { - Object.assign>(provisioningFeatures, { - schulconnexGroupProvisioningEnabled: true, - }); - }); - afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + validationFunction.mockReset(); + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = false; + config.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED = false; }); const setupSchulconnexResponse = (): SchulconnexResponse => schulconnexResponseFactory.build(); @@ -179,13 +170,14 @@ describe(SanisProvisioningStrategy.name, () => { schulconnexLizenzInfoResponse, ]); + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; + config.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED = true; schulconnexRestClient.getPersonInfo.mockResolvedValueOnce(schulconnexResponse); mapper.mapToExternalUserDto.mockReturnValue(user); mapper.mapToExternalSchoolDto.mockReturnValue(school); mapper.mapToExternalGroupDtos.mockReturnValue(groups); validationFunction.mockResolvedValueOnce([]); validationFunction.mockResolvedValueOnce([]); - configService.get.mockReturnValueOnce(true); schulconnexRestClient.getLizenzInfo.mockResolvedValueOnce(schulconnexLizenzInfoResponses); validationFunction.mockResolvedValueOnce([]); @@ -287,7 +279,7 @@ describe(SanisProvisioningStrategy.name, () => { mapper.mapToExternalSchoolDto.mockReturnValue(school); validationFunction.mockResolvedValueOnce([]); - provisioningFeatures.schulconnexGroupProvisioningEnabled = false; + config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = false; return { input, @@ -342,7 +334,7 @@ describe(SanisProvisioningStrategy.name, () => { validationFunction.mockResolvedValueOnce([]); validationFunction.mockResolvedValueOnce([]); - configService.get.mockReturnValueOnce(false); + config.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED = false; return { input, diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts index b5ddfff8238..e55305ade14 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts @@ -5,14 +5,13 @@ import { } from '@infra/schulconnex-client/response'; import { SchulconnexRestClient } from '@infra/schulconnex-client/schulconnex-rest-client'; import { GroupService } from '@modules/group/service/group.service'; -import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ValidationErrorLoggableException } from '@shared/common/loggable-exception'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { plainToClass } from 'class-transformer'; import { validate, ValidationError } from 'class-validator'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, ExternalLicenseDto, @@ -36,7 +35,6 @@ import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; @Injectable() export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { constructor( - @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, protected readonly schulconnexSchoolProvisioningService: SchulconnexSchoolProvisioningService, protected readonly schulconnexUserProvisioningService: SchulconnexUserProvisioningService, protected readonly schulconnexGroupProvisioningService: SchulconnexGroupProvisioningService, @@ -44,12 +42,11 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { protected readonly groupService: GroupService, protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, + protected readonly configService: ConfigService, private readonly responseMapper: SchulconnexResponseMapper, - private readonly schulconnexRestClient: SchulconnexRestClient, - protected readonly configService: ConfigService + private readonly schulconnexRestClient: SchulconnexRestClient ) { super( - provisioningFeatures, schulconnexSchoolProvisioningService, schulconnexUserProvisioningService, schulconnexGroupProvisioningService, @@ -92,7 +89,7 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { const externalSchool: ExternalSchoolDto = this.responseMapper.mapToExternalSchoolDto(schulconnexResponse); let externalGroups: ExternalGroupDto[] | undefined; - if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { + if (this.configService.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { await this.checkResponseValidation(schulconnexResponse, [SchulconnexResponseValidationGroups.GROUPS]); externalGroups = this.responseMapper.mapToExternalGroupDtos(schulconnexResponse); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts index 86a91085ec6..4173c8d2ed0 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts @@ -3,26 +3,28 @@ import { SchulconnexGroupRole, SchulconnexGroupType, SchulconnexGruppenResponse, + SchulconnexLizenzInfoResponse, schulconnexLizenzInfoResponseFactory, SchulconnexPersonenkontextResponse, SchulconnexResponse, schulconnexResponseFactory, SchulconnexSonstigeGruppenzugehoerigeResponse, } from '@infra/schulconnex-client'; -import { SchulconnexLizenzInfoResponse } from '@infra/schulconnex-client/response'; import { GroupTypes } from '@modules/group'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain/interface'; import { Logger } from '@src/core/logger'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; +import { InvalidLaufzeitResponseLoggableException, InvalidLernperiodeResponseLoggableException } from '../../domain'; import { ExternalGroupDto, ExternalLicenseDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; +import { ProvisioningConfig } from '../../provisioning.config'; import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; describe(SchulconnexResponseMapper.name, () => { let module: TestingModule; let mapper: SchulconnexResponseMapper; - let provisioningFeatures: IProvisioningFeatures; + const config: Partial = {}; beforeAll(async () => { module = await Test.createTestingModule({ @@ -33,14 +35,15 @@ describe(SchulconnexResponseMapper.name, () => { useValue: createMock(), }, { - provide: ProvisioningFeatures, - useValue: {}, + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), + }, }, ], }).compile(); mapper = module.get(SchulconnexResponseMapper); - provisioningFeatures = module.get(ProvisioningFeatures); }); describe('mapToExternalSchoolDto', () => { @@ -142,11 +145,10 @@ describe(SchulconnexResponseMapper.name, () => { describe('when group type class is given', () => { const setup = () => { - Object.assign>(provisioningFeatures, { - schulconnexOtherGroupusersEnabled: true, - }); + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + const personenkontext: SchulconnexPersonenkontextResponse = schulconnexResponse.personenkontexte[0]; const group: SchulconnexGruppenResponse = personenkontext.gruppen![0]; const otherParticipant: SchulconnexSonstigeGruppenzugehoerigeResponse = group.sonstige_gruppenzugehoerige![0]; @@ -178,6 +180,8 @@ describe(SchulconnexResponseMapper.name, () => { roleName: RoleName.STUDENT, }, ], + from: new Date('2024-08-01'), + until: new Date('2025-07-31'), }); }); }); @@ -270,9 +274,7 @@ describe(SchulconnexResponseMapper.name, () => { describe('when no other participants are provided and FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED is false', () => { const setup = () => { - Object.assign>(provisioningFeatures, { - schulconnexOtherGroupusersEnabled: false, - }); + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = false; const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); schulconnexResponse.personenkontexte[0].gruppen![0].sonstige_gruppenzugehoerige = undefined; @@ -292,9 +294,7 @@ describe(SchulconnexResponseMapper.name, () => { describe('when no other participants are provided and FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED is true', () => { const setup = () => { - Object.assign>(provisioningFeatures, { - schulconnexOtherGroupusersEnabled: true, - }); + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); schulconnexResponse.personenkontexte[0].gruppen![0].sonstige_gruppenzugehoerige = undefined; @@ -336,6 +336,226 @@ describe(SchulconnexResponseMapper.name, () => { expect(result?.[0].otherUsers).toHaveLength(0); }); }); + + describe('when the group has no duration', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = undefined; + + return { + schulconnexResponse, + }; + }; + + it('should map the group without a duration', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + from: undefined, + until: undefined, + }), + ]); + }); + }); + + describe('when the group has a duration as lernperiode', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = { + vonlernperiode: '2023-2', + bislernperiode: '2026-1', + }; + + return { + schulconnexResponse, + }; + }; + + it('should map the group a duration', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + from: new Date('2024-02-01'), + until: new Date('2027-01-31'), + }), + ]); + }); + }); + + describe('when the group has a duration as an exact date', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + const duration = { + von: '2024-05-13', + bis: '2028-07-12', + }; + + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = duration; + + return { + schulconnexResponse, + duration, + }; + }; + + it('should map the group with an exact date', () => { + const { schulconnexResponse, duration } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + from: new Date(duration.von), + until: new Date(duration.bis), + }), + ]); + }); + }); + + describe('when the group has a duration as an exact date and as lernperiode', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + const duration = { + von: '2024-05-13', + bis: '2028-07-12', + vonlernperiode: '2024', + bislernperiode: '2025', + }; + + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = duration; + + return { + schulconnexResponse, + duration, + }; + }; + + it('should map the group with an exact date', () => { + const { schulconnexResponse, duration } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + from: new Date(duration.von), + until: new Date(duration.bis), + }), + ]); + }); + }); + + describe('when the group has an invalid duration as lernperiode', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = { + vonlernperiode: '2024-3', + bislernperiode: '2021-01-02', + }; + + return { + schulconnexResponse, + }; + }; + + it('should throw an error', () => { + const { schulconnexResponse } = setup(); + + expect(() => mapper.mapToExternalGroupDtos(schulconnexResponse)).toThrow( + InvalidLernperiodeResponseLoggableException + ); + }); + }); + + describe('when the group has no from date', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = { + bislernperiode: '2024-2', + }; + + return { + schulconnexResponse, + }; + }; + + it('should throw an error', () => { + const { schulconnexResponse } = setup(); + + expect(() => mapper.mapToExternalGroupDtos(schulconnexResponse)).toThrow( + InvalidLaufzeitResponseLoggableException + ); + }); + }); + + describe('when the group has no until date', () => { + const setup = () => { + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + schulconnexResponse.personenkontexte[0].gruppen![0]!.gruppe.laufzeit = { + vonlernperiode: '2024-2', + }; + + return { + schulconnexResponse, + }; + }; + + it('should throw an error', () => { + const { schulconnexResponse } = setup(); + + expect(() => mapper.mapToExternalGroupDtos(schulconnexResponse)).toThrow( + InvalidLaufzeitResponseLoggableException + ); + }); + }); + }); + + describe('mapLernperiode', () => { + describe('when the lernperiode is a full year', () => { + it('should map the correct date', () => { + const result = SchulconnexResponseMapper.mapLernperiode('2024'); + + expect(result).toEqual({ + from: new Date('2024-08-01'), + until: new Date('2025-07-31'), + }); + }); + }); + + describe('when the lernperiode is the first half year', () => { + it('should map the correct date', () => { + const result = SchulconnexResponseMapper.mapLernperiode('2024-1'); + + expect(result).toEqual({ + from: new Date('2024-08-01'), + until: new Date('2025-01-31'), + }); + }); + }); + + describe('when the lernperiode is the second half year', () => { + it('should map the correct date', () => { + const result = SchulconnexResponseMapper.mapLernperiode('2024-2'); + + expect(result).toEqual({ + from: new Date('2025-02-01'), + until: new Date('2025-07-31'), + }); + }); + }); + + describe('when the lernperiode is invalid', () => { + it('should throw an error', () => { + expect(() => SchulconnexResponseMapper.mapLernperiode('2024-3')).toThrow( + InvalidLernperiodeResponseLoggableException + ); + }); + }); }); describe('mapToExternalLicenses', () => { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts index aef7f09366d..34a31f9a55e 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts @@ -1,21 +1,22 @@ import { - SchulconnexGruppenResponse, - SchulconnexResponse, - SchulconnexSonstigeGruppenzugehoerigeResponse, -} from '@infra/schulconnex-client'; -import { + lernperiodeFormat, SchulconnexCommunicationType, SchulconnexErreichbarkeitenResponse, + SchulconnexGroupRole, + SchulconnexGroupType, + SchulconnexGruppenResponse, + SchulconnexLaufzeitResponse, SchulconnexLizenzInfoResponse, + SchulconnexResponse, + SchulconnexRole, + SchulconnexSonstigeGruppenzugehoerigeResponse, } from '@infra/schulconnex-client/response'; -import { SchulconnexGroupRole } from '@infra/schulconnex-client/response/schulconnex-group-role'; -import { SchulconnexGroupType } from '@infra/schulconnex-client/response/schulconnex-group-type'; -import { SchulconnexRole } from '@infra/schulconnex-client/response/schulconnex-role'; import { GroupTypes } from '@modules/group'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { RoleName } from '@shared/domain/interface'; import { Logger } from '@src/core/logger'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; +import { InvalidLaufzeitResponseLoggableException, InvalidLernperiodeResponseLoggableException } from '../../domain'; import { ExternalGroupDto, ExternalGroupUserDto, @@ -24,6 +25,7 @@ import { ExternalUserDto, } from '../../dto'; import { GroupRoleUnknownLoggable } from '../../loggable'; +import { ProvisioningConfig } from '../../provisioning.config'; const RoleMapping: Record = { [SchulconnexRole.LEHR]: RoleName.TEACHER, @@ -43,12 +45,17 @@ const GroupTypeMapping: Partial> = { [SchulconnexGroupType.OTHER]: GroupTypes.OTHER, }; +type TimePeriode = { + from: Date; + until: Date; +}; + @Injectable() export class SchulconnexResponseMapper { SCHOOLNUMBER_PREFIX_REGEX = /^NI_/; constructor( - @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, + private readonly configService: ConfigService, private readonly logger: Logger ) {} @@ -135,7 +142,7 @@ export class SchulconnexResponseMapper { } let otherUsers: ExternalGroupUserDto[] | undefined; - if (this.provisioningFeatures.schulconnexOtherGroupusersEnabled) { + if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED')) { otherUsers = group.sonstige_gruppenzugehoerige ? (group.sonstige_gruppenzugehoerige .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) @@ -143,13 +150,19 @@ export class SchulconnexResponseMapper { : []; } - return new ExternalGroupDto({ + const groupDuration: TimePeriode | undefined = SchulconnexResponseMapper.mapGroupDuration(group.gruppe.laufzeit); + + const externalGroup: ExternalGroupDto = new ExternalGroupDto({ name: group.gruppe.bezeichnung, type: groupType, externalId: group.gruppe.id, user, otherUsers, + from: groupDuration?.from, + until: groupDuration?.until, }); + + return externalGroup; } private mapToExternalGroupUser(relation: SchulconnexSonstigeGruppenzugehoerigeResponse): ExternalGroupUserDto | null { @@ -172,6 +185,61 @@ export class SchulconnexResponseMapper { return mapped; } + private static mapGroupDuration(duration: SchulconnexLaufzeitResponse | undefined): TimePeriode | undefined { + if (!duration) { + return undefined; + } + + let from: Date; + let until: Date; + if (duration.von) { + from = new Date(duration.von); + } else if (duration.vonlernperiode) { + const fromPeriode: TimePeriode = SchulconnexResponseMapper.mapLernperiode(duration.vonlernperiode); + + from = fromPeriode.from; + } else { + throw new InvalidLaufzeitResponseLoggableException(duration); + } + + if (duration.bis) { + until = new Date(duration.bis); + } else if (duration.bislernperiode) { + const untilPeriode: TimePeriode = SchulconnexResponseMapper.mapLernperiode(duration.bislernperiode); + + until = untilPeriode.until; + } else { + throw new InvalidLaufzeitResponseLoggableException(duration); + } + + return { + from, + until, + }; + } + + public static mapLernperiode(lernperiode: string): TimePeriode { + const matches: RegExpMatchArray | null = lernperiode.match(lernperiodeFormat); + + if (!matches || matches.length < 2) { + throw new InvalidLernperiodeResponseLoggableException(lernperiode); + } + + const year = Number(matches[1]); + const semester: number = matches.length >= 3 ? Number(matches[2]) : 0; + + const startMonth: string = semester === 2 ? '02' : '08'; + const endMonth: string = semester === 1 ? '01' : '07'; + + const startYear: number = semester === 2 ? year + 1 : year; + const endYear: number = year + 1; + + return { + from: new Date(`${startYear}-${startMonth}-01`), + until: new Date(`${endYear}-${endMonth}-31`), + }; + } + public static mapToExternalLicenses(licenseInfos: SchulconnexLizenzInfoResponse[]): ExternalLicenseDto[] { const externalLicenseDtos: ExternalLicenseDto[] = licenseInfos .map((license: SchulconnexLizenzInfoResponse) => { diff --git a/apps/server/src/modules/school/api/test/school-patch.api.spec.ts b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts index 336aae739e8..eb255861f24 100644 --- a/apps/server/src/modules/school/api/test/school-patch.api.spec.ts +++ b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts @@ -1,16 +1,17 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { - TestApiClient, - UserAndAccountTestFactory, cleanupCollections, countyEmbeddableFactory, federalStateFactory, schoolEntityFactory, schoolYearFactory, systemEntityFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; import { SchoolErrorEnum } from '../../domain/error'; diff --git a/apps/server/src/modules/school/domain/do/school.ts b/apps/server/src/modules/school/domain/do/school.ts index 0ae47a767e6..45ea43d255d 100644 --- a/apps/server/src/modules/school/domain/do/school.ts +++ b/apps/server/src/modules/school/domain/do/school.ts @@ -20,6 +20,18 @@ interface SchoolInfo { } export class School extends DomainObject { + get systems(): EntityId[] { + return this.props.systemIds; + } + + set externalId(externalId: string | undefined) { + this.props.externalId = externalId; + } + + set ldapLastSync(ldapLastSync: string | undefined) { + this.props.ldapLastSync = ldapLastSync; + } + public getInfo(): SchoolInfo { const info = { id: this.props.id, @@ -100,15 +112,13 @@ export class School extends DomainObject { public hasSystem(systemId: EntityId): boolean { const { systemIds } = this.props; - const result = systemIds?.includes(systemId) ?? false; + const result = systemIds.includes(systemId); return result; } public removeSystem(systemId: EntityId) { - if (this.props.systemIds) { - this.props.systemIds = this.props.systemIds.filter((id) => id !== systemId); - } + this.props.systemIds = this.props.systemIds.filter((id) => id !== systemId); } } @@ -127,7 +137,7 @@ export interface SchoolProps extends AuthorizableObject { purpose?: SchoolPurpose; features: Set; instanceFeatures?: Set; - systemIds?: EntityId[]; + systemIds: EntityId[]; logo?: SchoolLogo; fileStorageType?: FileStorageType; language?: LanguageType; @@ -137,4 +147,5 @@ export interface SchoolProps extends AuthorizableObject { // It can't be mapped to a feature straight-forwardly, // because the config value STUDENT_TEAM_CREATION has to be taken into account. enableStudentTeamCreation?: boolean; + ldapLastSync?: string; } diff --git a/apps/server/src/modules/school/domain/event-handler/index.ts b/apps/server/src/modules/school/domain/event-handler/index.ts new file mode 100644 index 00000000000..6b3cdd17830 --- /dev/null +++ b/apps/server/src/modules/school/domain/event-handler/index.ts @@ -0,0 +1 @@ +export { SystemDeletedHandler } from './system-deleted.handler'; diff --git a/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts new file mode 100644 index 00000000000..e4372297239 --- /dev/null +++ b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts @@ -0,0 +1,107 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { SystemDeletedEvent } from '@modules/system'; +import { Test, TestingModule } from '@nestjs/testing'; +import { systemFactory } from '@shared/testing'; +import { schoolFactory } from '../../testing'; +import { School } from '../do'; +import { SchoolService } from '../service'; +import { SystemDeletedHandler } from './system-deleted.handler'; + +describe(SystemDeletedHandler.name, () => { + let module: TestingModule; + let handler: SystemDeletedHandler; + + let schoolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SystemDeletedHandler, + { + provide: SchoolService, + useValue: createMock(), + }, + ], + }).compile(); + + handler = module.get(SystemDeletedHandler); + schoolService = module.get(SchoolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handle', () => { + describe('when a non-ldap system is removed', () => { + const setup = () => { + const system = systemFactory.withOauthConfig().build(); + const school = schoolFactory.build({ + systemIds: [system.id], + ldapLastSync: new Date().toISOString(), + externalId: 'schoolExternalId', + }); + const event = new SystemDeletedEvent({ schoolId: school.id, system }); + + schoolService.getSchoolById.mockResolvedValueOnce(new School({ ...school.getProps() })); + + return { + school, + event, + }; + }; + + it('should should remove the system and save the school', async () => { + const { school, event } = setup(); + + await handler.handle(event); + + expect(schoolService.save).toHaveBeenCalledWith( + new School({ + ...school.getProps(), + systemIds: [], + ldapLastSync: undefined, + }) + ); + }); + }); + + describe('when the last ldap system is removed', () => { + const setup = () => { + const system = systemFactory.withLdapConfig().build(); + const school = schoolFactory.build({ + systemIds: [system.id], + ldapLastSync: new Date().toISOString(), + externalId: 'schoolExternalId', + }); + const event = new SystemDeletedEvent({ schoolId: school.id, system }); + + schoolService.getSchoolById.mockResolvedValueOnce(new School({ ...school.getProps() })); + + return { + school, + event, + }; + }; + + it('should should remove the system and save the school', async () => { + const { school, event } = setup(); + + await handler.handle(event); + + expect(schoolService.save).toHaveBeenCalledWith( + new School({ + ...school.getProps(), + systemIds: [], + ldapLastSync: undefined, + externalId: undefined, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.ts b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.ts new file mode 100644 index 00000000000..6868ce0cf89 --- /dev/null +++ b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.ts @@ -0,0 +1,24 @@ +import { SystemDeletedEvent, SystemType } from '@modules/system/domain'; +import { Injectable } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { School } from '../do'; +import { SchoolService } from '../service'; + +@Injectable() +@EventsHandler(SystemDeletedEvent) +export class SystemDeletedHandler implements IEventHandler { + constructor(private readonly schoolService: SchoolService) {} + + public async handle(event: SystemDeletedEvent): Promise { + const school: School = await this.schoolService.getSchoolById(event.schoolId); + + school.removeSystem(event.system.id); + school.ldapLastSync = undefined; + + if (event.system.type === SystemType.LDAP && school.systems.length === 0) { + school.externalId = undefined; + } + + await this.schoolService.save(school); + } +} diff --git a/apps/server/src/modules/school/domain/factory/school.factory.spec.ts b/apps/server/src/modules/school/domain/factory/school.factory.spec.ts index f01c10e257f..eba0e00d007 100644 --- a/apps/server/src/modules/school/domain/factory/school.factory.spec.ts +++ b/apps/server/src/modules/school/domain/factory/school.factory.spec.ts @@ -1,14 +1,13 @@ import { LanguageType } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; -import { federalStateFactory } from '../../testing'; -import { School } from '../do'; +import { federalStateFactory, schoolFactory } from '../../testing'; import { FileStorageType } from '../type'; import { SchoolFactory } from './school.factory'; describe('SchoolFactory', () => { describe('buildFromPartialBody', () => { const buildSchool = () => { - const school = new School({ + const school = schoolFactory.build({ id: 'school-id', name: 'school-name', officialSchoolNumber: 'school-number', diff --git a/apps/server/src/modules/school/domain/service/school.service.spec.ts b/apps/server/src/modules/school/domain/service/school.service.spec.ts index 8c2ecbd0e6d..556b4ee89be 100644 --- a/apps/server/src/modules/school/domain/service/school.service.spec.ts +++ b/apps/server/src/modules/school/domain/service/school.service.spec.ts @@ -510,6 +510,36 @@ describe('SchoolService', () => { }); }); + describe('save', () => { + describe('when saving a school', () => { + const setup = () => { + const school = schoolFactory.build({ name: 'old name' }); + + schoolRepo.save.mockResolvedValueOnce(school); + + return { + school, + }; + }; + + it('should save the school', async () => { + const { school } = setup(); + + await service.save(school); + + expect(schoolRepo.save).toHaveBeenCalledWith(school); + }); + + it('should return the updated school', async () => { + const { school } = setup(); + + const result = await service.save(school); + + expect(result).toEqual(school); + }); + }); + }); + describe('getSchoolSystems', () => { describe('when school has systems', () => { const setup = () => { diff --git a/apps/server/src/modules/school/domain/service/school.service.ts b/apps/server/src/modules/school/domain/service/school.service.ts index cf22c9a7dd0..b590a6250f5 100644 --- a/apps/server/src/modules/school/domain/service/school.service.ts +++ b/apps/server/src/modules/school/domain/service/school.service.ts @@ -1,14 +1,17 @@ +import { System, SystemService } from '@modules/system'; import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeGuard } from '@shared/common'; import { IFindOptions } from '@shared/domain/interface/find-options'; import { EntityId } from '@shared/domain/types/entity-id'; -import { System, SystemService } from '@src/modules/system'; import { SchoolConfig } from '../../school.config'; import { School, SchoolProps, SystemForLdapLogin } from '../do'; import { SchoolForLdapLogin, SchoolForLdapLoginProps } from '../do/school-for-ldap-login'; -import { SchoolHasNoSystemLoggableException, SystemCanNotBeDeletedLoggableException } from '../error'; -import { SystemNotFoundLoggableException } from '../error/system-not-found.loggable-exception'; +import { + SchoolHasNoSystemLoggableException, + SystemCanNotBeDeletedLoggableException, + SystemNotFoundLoggableException, +} from '../error'; import { SchoolFactory } from '../factory'; import { SCHOOL_REPO, SchoolRepo, SchoolUpdateBody } from '../interface'; import { SchoolQuery } from '../query'; @@ -89,7 +92,7 @@ export class SchoolService { return schoolsForLdapLogin; } - public async updateSchool(school: School, body: SchoolUpdateBody) { + public async updateSchool(school: School, body: SchoolUpdateBody): Promise { const fullSchoolObject = SchoolFactory.buildFromPartialBody(school, body); let updatedSchool = await this.schoolRepo.save(fullSchoolObject); @@ -98,6 +101,12 @@ export class SchoolService { return updatedSchool; } + public async save(school: School): Promise { + const updatedSchool: School = await this.schoolRepo.save(school); + + return updatedSchool; + } + public async removeSystemFromSchool(school: School, systemId: EntityId): Promise { if (!school.hasSystem(systemId)) { throw new SchoolHasNoSystemLoggableException(school.id, systemId); @@ -110,7 +119,7 @@ export class SchoolService { await this.schoolRepo.save(school); } - private async tryFindAndRemoveSystem(systemId: string) { + private async tryFindAndRemoveSystem(systemId: string): Promise { const system = await this.systemService.findById(systemId); if (!system) { throw new SystemNotFoundLoggableException(systemId); diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts index d045785e78e..b1838ae2185 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts @@ -1,4 +1,4 @@ -import { SystemEntity } from '@shared/domain/entity'; +import { SystemEntity } from '@modules/system/entity'; import { schoolEntityFactory, setupEntities } from '@shared/testing'; import { School } from '../../../domain'; import { CountyEmbeddableMapper } from './county.embeddable.mapper'; diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts index 993d3897216..7d5bc0f8396 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts @@ -1,6 +1,7 @@ import { EntityData } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; -import { FederalStateEntity, SchoolYearEntity, SystemEntity } from '@shared/domain/entity'; +import { SystemEntity } from '@modules/system/entity'; +import { FederalStateEntity, SchoolYearEntity } from '@shared/domain/entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { SchoolFactory } from '@src/modules/school/domain/factory'; import { School } from '../../../domain'; @@ -39,6 +40,7 @@ export class SchoolEntityMapper { federalState, features, county, + ldapLastSync: entity.ldapLastSync, }); return school; diff --git a/apps/server/src/modules/school/school.module.ts b/apps/server/src/modules/school/school.module.ts index d9ebac6e83e..dbb158d633e 100644 --- a/apps/server/src/modules/school/school.module.ts +++ b/apps/server/src/modules/school/school.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { SystemModule } from '../system'; import { SCHOOL_REPO, SCHOOL_YEAR_REPO, SchoolService, SchoolYearService } from './domain'; +import { SystemDeletedHandler } from './domain/event-handler'; import { SchoolYearMikroOrmRepo } from './repo/mikro-orm/school-year.repo'; import { SchoolMikroOrmRepo } from './repo/mikro-orm/school.repo'; @@ -11,6 +12,7 @@ import { SchoolMikroOrmRepo } from './repo/mikro-orm/school.repo'; SchoolYearService, { provide: SCHOOL_REPO, useClass: SchoolMikroOrmRepo }, { provide: SCHOOL_YEAR_REPO, useClass: SchoolYearMikroOrmRepo }, + SystemDeletedHandler, ], exports: [SchoolService, SchoolYearService], }) diff --git a/apps/server/src/modules/school/testing/school.factory.ts b/apps/server/src/modules/school/testing/school.factory.ts index ccb05fde5ee..e44195c9cbb 100644 --- a/apps/server/src/modules/school/testing/school.factory.ts +++ b/apps/server/src/modules/school/testing/school.factory.ts @@ -1,6 +1,6 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { SchoolFeature } from '@shared/domain/types'; import { BaseFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; import { School, SchoolProps } from '../domain'; import { federalStateFactory } from './federal-state.factory'; @@ -12,5 +12,6 @@ export const schoolFactory = BaseFactory.define(School, ({ name: `school #${sequence}`, federalState: federalStateFactory.build(), features: new Set(), + systemIds: [], }; }); diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 3110d7761f4..9fe1276ab7f 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -244,7 +244,7 @@ export class ConfigResponse { this.FEATURE_LESSON_SHARE = config.FEATURE_LESSON_SHARE; this.FEATURE_TASK_SHARE = config.FEATURE_TASK_SHARE; this.FEATURE_BOARD_LAYOUT_ENABLED = config.FEATURE_BOARD_LAYOUT_ENABLED; - this.FEATURE_USER_MIGRATION_ENABLED = config.userMigrationEnabled; + this.FEATURE_USER_MIGRATION_ENABLED = config.FEATURE_USER_MIGRATION_ENABLED; this.FEATURE_COPY_SERVICE_ENABLED = config.FEATURE_COPY_SERVICE_ENABLED; this.FEATURE_CONSENT_NECESSARY = config.FEATURE_CONSENT_NECESSARY; this.FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED = config.FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED; @@ -266,20 +266,20 @@ export class ConfigResponse { this.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED = config.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED; this.MIGRATION_END_GRACE_PERIOD_MS = config.MIGRATION_END_GRACE_PERIOD_MS; - this.FEATURE_CTL_TOOLS_TAB_ENABLED = config.ctlToolsTabEnabled; - this.FEATURE_LTI_TOOLS_TAB_ENABLED = config.ltiToolsTabEnabled; + this.FEATURE_CTL_TOOLS_TAB_ENABLED = config.FEATURE_CTL_TOOLS_TAB_ENABLED; + this.FEATURE_LTI_TOOLS_TAB_ENABLED = config.FEATURE_LTI_TOOLS_TAB_ENABLED; this.FEATURE_SHOW_OUTDATED_USERS = config.FEATURE_SHOW_OUTDATED_USERS; this.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION = config.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION; - this.CTL_TOOLS_RELOAD_TIME_MS = config.ctlToolsReloadTimeMs; + this.CTL_TOOLS_RELOAD_TIME_MS = config.CTL_TOOLS_RELOAD_TIME_MS; this.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED; - this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.ctlToolsCopyEnabled; + this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.FEATURE_CTL_TOOLS_COPY_ENABLED; this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD; this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK; this.FEATURE_TLDRAW_ENABLED = config.FEATURE_TLDRAW_ENABLED; this.TLDRAW__ASSETS_ENABLED = config.TLDRAW__ASSETS_ENABLED; this.TLDRAW__ASSETS_MAX_SIZE = config.TLDRAW__ASSETS_MAX_SIZE; this.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST = config.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST; - this.FEATURE_VIDEOCONFERENCE_ENABLED = config.enabled; + this.FEATURE_VIDEOCONFERENCE_ENABLED = config.FEATURE_VIDEOCONFERENCE_ENABLED; this.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED; this.FEATURE_MEDIA_SHELF_ENABLED = config.FEATURE_MEDIA_SHELF_ENABLED; this.BOARD_COLLABORATION_URI = config.BOARD_COLLABORATION_URI; diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index a5d2b6ddf45..3b55a49ee4a 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -16,16 +16,16 @@ import { ProvisioningConfig } from '@modules/provisioning'; import type { SchoolConfig } from '@modules/school'; import type { SharingConfig } from '@modules/sharing'; import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; -import { ToolConfiguration, type IToolFeatures } from '@modules/tool'; +import type { ToolConfig } from '@modules/tool/tool-config'; import type { UserConfig } from '@modules/user'; -import { UserImportConfiguration, type IUserImportFeatures } from '@modules/user-import'; +import type { UserImportConfig } from '@modules/user-import'; import type { UserLoginMigrationConfig } from '@modules/user-login-migration'; -import { VideoConferenceConfiguration, type IVideoConferenceSettings } from '@modules/video-conference'; +import { VideoConferenceConfig } from '@modules/video-conference'; import { LanguageType } from '@shared/domain/interface'; import { SchulcloudTheme } from '@shared/domain/types'; import type { CoreModuleConfig } from '@src/core'; import type { MailConfig } from '@src/infra/mail/interfaces/mail-config'; -import { UserImportConfig } from '../user-import/user-import-config'; +import { BbbConfig } from '../video-conference/bbb'; import { Timezone } from './types/timezone.enum'; export enum NodeEnvType { @@ -48,24 +48,26 @@ export interface ServerConfig XApiKeyConfig, LearnroomConfig, AuthenticationConfig, - IToolFeatures, + ToolConfig, TldrawClientConfig, UserLoginMigrationConfig, LessonConfig, - IVideoConferenceSettings, BoardConfig, MediaBoardConfig, SharingConfig, - IUserImportFeatures, + UserImportConfig, SchulconnexClientConfig, SynchronizationConfig, DeletionConfig, CollaborativeTextEditorConfig, ProvisioningConfig, UserImportConfig, + VideoConferenceConfig, + BbbConfig, AlertConfig { NODE_ENV: NodeEnvType; SC_DOMAIN: string; + HOST: string; ACCESSIBILITY_REPORT_EMAIL: string; ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; ALERT_STATUS_URL: string | null; @@ -254,9 +256,6 @@ const config: ServerConfig = { ) as number, FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, ...getTldrawClientConfig(), - ...ToolConfiguration.toolFeatures, - ...VideoConferenceConfiguration.videoConference, - ...UserImportConfiguration.userImportFeatures, FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: Configuration.get( 'FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED' @@ -266,6 +265,25 @@ const config: ServerConfig = { PROVISIONING_SCHULCONNEX_LIZENZ_INFO_URL: Configuration.get('PROVISIONING_SCHULCONNEX_LIZENZ_INFO_URL') as string, BOARD_COLLABORATION_URI: Configuration.get('BOARD_COLLABORATION_URI') as string, FEATURE_NEW_LAYOUT_ENABLED: Configuration.get('FEATURE_NEW_LAYOUT_ENABLED') as boolean, + FEATURE_CTL_TOOLS_TAB_ENABLED: Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED') as boolean, + FEATURE_LTI_TOOLS_TAB_ENABLED: Configuration.get('FEATURE_LTI_TOOLS_TAB_ENABLED') as boolean, + CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES: Configuration.get( + 'CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES' + ) as number, + CTL_TOOLS_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, + FEATURE_CTL_TOOLS_COPY_ENABLED: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean, + CTL_TOOLS_RELOAD_TIME_MS: Configuration.get('CTL_TOOLS_RELOAD_TIME_MS') as number, + HOST: Configuration.get('HOST') as string, + FEATURE_VIDEOCONFERENCE_ENABLED: Configuration.get('FEATURE_VIDEOCONFERENCE_ENABLED') as boolean, + VIDEOCONFERENCE_HOST: Configuration.get('VIDEOCONFERENCE_HOST') as string, + VIDEOCONFERENCE_SALT: Configuration.get('VIDEOCONFERENCE_SALT') as string, + VIDEOCONFERENCE_DEFAULT_PRESENTATION: Configuration.get('VIDEOCONFERENCE_DEFAULT_PRESENTATION') as string, + FEATURE_USER_MIGRATION_ENABLED: Configuration.get('FEATURE_USER_MIGRATION_ENABLED') as boolean, + FEATURE_USER_MIGRATION_SYSTEM_ID: Configuration.get('FEATURE_USER_MIGRATION_SYSTEM_ID') as string, + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: Configuration.get( + 'FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION' + ) as boolean, + FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index b28498f9b1e..ed1060ec737 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -8,6 +8,7 @@ import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AccountApiModule } from '@modules/account/account-api.module'; import { AlertModule } from '@modules/alert/alert.module'; import { AuthenticationApiModule } from '@modules/authentication/authentication-api.module'; +import { AuthorizationReferenceApiModule } from '@modules/authorization/authorization-reference.api.module'; import { BoardApiModule } from '@modules/board/board-api.module'; import { MediaBoardApiModule } from '@modules/board/media-board-api.module'; import { CollaborativeStorageModule } from '@modules/collaborative-storage'; @@ -30,7 +31,8 @@ import { SystemApiModule } from '@modules/system/system-api.module'; import { TaskApiModule } from '@modules/task/task-api.module'; import { TeamsApiModule } from '@modules/teams/teams-api.module'; import { ToolApiModule } from '@modules/tool/tool-api.module'; -import { ImportUserModule, UserImportConfigModule } from '@modules/user-import'; +import { ImportUserModule } from '@modules/user-import'; +import { UserLicenseModule } from '@modules/user-license'; import { UserLoginMigrationApiModule } from '@modules/user-login-migration/user-login-migration-api.module'; import { UsersAdminApiModule } from '@modules/user/legacy/users-admin-api.module'; import { UserApiModule } from '@modules/user/user-api.module'; @@ -41,8 +43,6 @@ import { ALL_ENTITIES } from '@shared/domain/entity'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; -import { UserLicenseModule } from '@modules/user-license'; -import { AuthorizationReferenceApiModule } from '@modules/authorization/authorization-reference.api.module'; import { ServerConfigController, ServerController, ServerUc } from './api'; import { SERVER_CONFIG_TOKEN, serverConfig } from './server.config'; @@ -62,7 +62,6 @@ const serverModules = [ UsersAdminApiModule, SchulconnexClientModule.registerAsync(), ImportUserModule, - UserImportConfigModule, LearnroomApiModule, FilesStorageClientModule, SystemApiModule, 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 4aaf7fff046..6631d18c2c9 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 @@ -2,9 +2,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfigEntity, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { schoolEntityFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { Response } from 'supertest'; +import { OauthConfigEntity, SystemEntity } from '../../entity'; import { PublicSystemListResponse, PublicSystemResponse } from '../dto'; const baseRouteName = '/systems'; diff --git a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts index f2649b75f9a..fabc3b95f21 100644 --- a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts +++ b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts @@ -85,20 +85,7 @@ export class OauthConfigResponse { }) jwksEndpoint: string; - constructor(oauthConfigResponse: { - redirectUri: string; - idpHint?: string; - tokenEndpoint: string; - responseType: string; - clientId: string; - provider: string; - jwksEndpoint: string; - authEndpoint: string; - scope: string; - logoutEndpoint?: string; - grantType: string; - issuer: string; - }) { + constructor(oauthConfigResponse: OauthConfigResponse) { this.clientId = oauthConfigResponse.clientId; this.idpHint = oauthConfigResponse.idpHint; this.redirectUri = oauthConfigResponse.redirectUri; diff --git a/apps/server/src/modules/system/controller/dto/public-system-response.ts b/apps/server/src/modules/system/controller/dto/public-system-response.ts index b00055a1849..c562b1a8729 100644 --- a/apps/server/src/modules/system/controller/dto/public-system-response.ts +++ b/apps/server/src/modules/system/controller/dto/public-system-response.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { OauthConfigResponse } from '@modules/system/controller/dto/oauth-config.response'; +import { OauthConfigResponse } from './oauth-config.response'; export class PublicSystemResponse { @ApiProperty({ diff --git a/apps/server/src/modules/system/controller/dto/system.filter.params.ts b/apps/server/src/modules/system/controller/dto/system.filter.params.ts index c2f49f55231..6cd966eb5bb 100644 --- a/apps/server/src/modules/system/controller/dto/system.filter.params.ts +++ b/apps/server/src/modules/system/controller/dto/system.filter.params.ts @@ -1,17 +1,13 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { StringToBoolean } from '@shared/controller'; -import { SystemTypeEnum } from '@shared/domain/types'; -import { IsBoolean, IsEnum, IsOptional } from 'class-validator'; +import { SingleValueToArrayTransformer } from '@shared/controller'; +import { IsArray, IsEnum, IsOptional } from 'class-validator'; +import { SystemType } from '../../domain'; export class SystemFilterParams { - @ApiPropertyOptional({ description: 'The type of the system.' }) + @ApiPropertyOptional({ description: 'The type of the system.', enum: SystemType, enumName: 'SystemType' }) + @SingleValueToArrayTransformer() @IsOptional() - @IsEnum(SystemTypeEnum) - type?: SystemTypeEnum; - - @ApiPropertyOptional({ description: 'Flag to request only systems with oauth-config.' }) - @IsOptional() - @IsBoolean() - @StringToBoolean() - onlyOauth?: boolean; + @IsArray() + @IsEnum(SystemType, { each: true }) + types?: SystemType[]; } diff --git a/apps/server/src/modules/system/controller/index.ts b/apps/server/src/modules/system/controller/index.ts new file mode 100644 index 00000000000..59c1fa7c892 --- /dev/null +++ b/apps/server/src/modules/system/controller/index.ts @@ -0,0 +1 @@ +export { SystemController } from './system.controller'; diff --git a/apps/server/src/modules/system/controller/mapper/system-response.mapper.ts b/apps/server/src/modules/system/controller/mapper/system-response.mapper.ts index 20a215a3dbe..0e4c4da8438 100644 --- a/apps/server/src/modules/system/controller/mapper/system-response.mapper.ts +++ b/apps/server/src/modules/system/controller/mapper/system-response.mapper.ts @@ -1,13 +1,10 @@ -import { OauthConfigResponse } from '@modules/system/controller/dto/oauth-config.response'; -import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { PublicSystemListResponse } from '../dto/public-system-list.response'; -import { PublicSystemResponse } from '../dto/public-system-response'; +import { OauthConfig, System } from '../../domain'; +import { OauthConfigResponse, PublicSystemListResponse, PublicSystemResponse } from '../dto'; export class SystemResponseMapper { - static mapFromDtoToListResponse(systems: SystemDto[]): PublicSystemListResponse { + static mapFromDtoToListResponse(systems: System[]): PublicSystemListResponse { const systemResponses: PublicSystemResponse[] = systems.map( - (system: SystemDto): PublicSystemResponse => this.mapFromDtoToResponse(system) + (system: System): PublicSystemResponse => this.mapFromDtoToResponse(system) ); const systemListResponse: PublicSystemListResponse = new PublicSystemListResponse(systemResponses); @@ -15,9 +12,9 @@ export class SystemResponseMapper { return systemListResponse; } - static mapFromDtoToResponse(system: SystemDto): PublicSystemResponse { + static mapFromDtoToResponse(system: System): PublicSystemResponse { const systemResponse: PublicSystemResponse = new PublicSystemResponse({ - id: system.id || '', + id: system.id, type: system.type, alias: system.alias, displayName: system.displayName, @@ -29,21 +26,21 @@ export class SystemResponseMapper { return systemResponse; } - static mapFromOauthConfigDtoToResponse(oauthConfigDto: OauthConfigDto): OauthConfigResponse { + static mapFromOauthConfigDtoToResponse(oauthConfig: OauthConfig): OauthConfigResponse { const oauthConfigResponse: OauthConfigResponse = new OauthConfigResponse({ - clientId: oauthConfigDto.clientId, + clientId: oauthConfig.clientId, // clientSecret will not be mapped for security reasons, - idpHint: oauthConfigDto.idpHint, - redirectUri: oauthConfigDto.redirectUri, - grantType: oauthConfigDto.grantType, - tokenEndpoint: oauthConfigDto.tokenEndpoint, - authEndpoint: oauthConfigDto.authEndpoint, - responseType: oauthConfigDto.responseType, - scope: oauthConfigDto.scope, - provider: oauthConfigDto.provider, - logoutEndpoint: oauthConfigDto.logoutEndpoint, - issuer: oauthConfigDto.issuer, - jwksEndpoint: oauthConfigDto.jwksEndpoint, + idpHint: oauthConfig.idpHint, + redirectUri: oauthConfig.redirectUri, + grantType: oauthConfig.grantType, + tokenEndpoint: oauthConfig.tokenEndpoint, + authEndpoint: oauthConfig.authEndpoint, + responseType: oauthConfig.responseType, + scope: oauthConfig.scope, + provider: oauthConfig.provider, + logoutEndpoint: oauthConfig.logoutEndpoint, + issuer: oauthConfig.issuer, + jwksEndpoint: oauthConfig.jwksEndpoint, }); return oauthConfigResponse; diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index a0630a8119a..ab115a0efdd 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -1,7 +1,7 @@ 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 { System } from '../domain'; import { SystemUc } from '../uc/system.uc'; import { PublicSystemListResponse, PublicSystemResponse, SystemFilterParams, SystemIdParams } from './dto'; import { SystemResponseMapper } from './mapper/system-response.mapper'; @@ -19,9 +19,9 @@ export class SystemController { @ApiOperation({ summary: 'Finds all publicly available systems.' }) @ApiResponse({ status: 200, type: PublicSystemListResponse, description: 'Returns a list of systems.' }) async find(@Query() filterParams: SystemFilterParams): Promise { - const systemDtos: SystemDto[] = await this.systemUc.findByFilter(filterParams.type, filterParams.onlyOauth); + const systems: System[] = await this.systemUc.find(filterParams.types); - const mapped: PublicSystemListResponse = SystemResponseMapper.mapFromDtoToListResponse(systemDtos); + const mapped: PublicSystemListResponse = SystemResponseMapper.mapFromDtoToListResponse(systems); return mapped; } @@ -34,9 +34,9 @@ export class SystemController { @ApiOperation({ summary: 'Finds a publicly available system.' }) @ApiResponse({ status: 200, type: PublicSystemResponse, description: 'Returns a system.' }) async getSystem(@Param() params: SystemIdParams): Promise { - const systemDto: SystemDto = await this.systemUc.findById(params.systemId); + const system: System = await this.systemUc.findById(params.systemId); - const mapped: PublicSystemResponse = SystemResponseMapper.mapFromDtoToResponse(systemDto); + const mapped: PublicSystemResponse = SystemResponseMapper.mapFromDtoToResponse(system); return mapped; } diff --git a/apps/server/src/modules/system/domain/event/index.ts b/apps/server/src/modules/system/domain/event/index.ts new file mode 100644 index 00000000000..8e3763014d0 --- /dev/null +++ b/apps/server/src/modules/system/domain/event/index.ts @@ -0,0 +1 @@ +export { SystemDeletedEvent } from './system-deleted.event'; diff --git a/apps/server/src/modules/system/domain/event/system-deleted.event.ts b/apps/server/src/modules/system/domain/event/system-deleted.event.ts new file mode 100644 index 00000000000..77324739bcf --- /dev/null +++ b/apps/server/src/modules/system/domain/event/system-deleted.event.ts @@ -0,0 +1,13 @@ +import { EntityId } from '@shared/domain/types'; +import { System } from '../system.do'; + +export class SystemDeletedEvent { + schoolId: EntityId; + + system: System; + + constructor(props: SystemDeletedEvent) { + this.schoolId = props.schoolId; + this.system = props.system; + } +} diff --git a/apps/server/src/modules/system/domain/factory/index.ts b/apps/server/src/modules/system/domain/factory/index.ts deleted file mode 100644 index 1fe32e7e8b0..00000000000 --- a/apps/server/src/modules/system/domain/factory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './system.factory'; diff --git a/apps/server/src/modules/system/domain/factory/system.factory.spec.ts b/apps/server/src/modules/system/domain/factory/system.factory.spec.ts deleted file mode 100644 index 69285dcb6de..00000000000 --- a/apps/server/src/modules/system/domain/factory/system.factory.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SystemType } from '../system-type.enum'; -import { System, SystemProps } from '../system.do'; -import { SystemFactory } from './system.factory'; - -describe('SystemFactory', () => { - describe('build', () => { - it('should return a system', () => { - const props: SystemProps = { - id: 'id', - type: SystemType.ISERV, - }; - - const result = SystemFactory.build(props); - - expect(result).toBeInstanceOf(System); - }); - }); -}); diff --git a/apps/server/src/modules/system/domain/factory/system.factory.ts b/apps/server/src/modules/system/domain/factory/system.factory.ts deleted file mode 100644 index dfcd31a4bbc..00000000000 --- a/apps/server/src/modules/system/domain/factory/system.factory.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { System, SystemProps } from '../system.do'; - -export class SystemFactory { - static build(props: SystemProps) { - return new System(props); - } -} diff --git a/apps/server/src/modules/system/domain/index.ts b/apps/server/src/modules/system/domain/index.ts index 128d3542fcc..217ea102212 100644 --- a/apps/server/src/modules/system/domain/index.ts +++ b/apps/server/src/modules/system/domain/index.ts @@ -1,6 +1,7 @@ -export { SystemFactory } from './factory/system.factory'; export * from './interface'; +export * from './event'; export { LdapConfig } from './ldap-config'; export { OauthConfig } from './oauth-config'; +export { OidcConfig } from './oidc-config'; export { SystemType } from './system-type.enum'; export { System, SystemProps } from './system.do'; diff --git a/apps/server/src/modules/system/domain/interface/index.ts b/apps/server/src/modules/system/domain/interface/index.ts index 89555b6b881..8e001841153 100644 --- a/apps/server/src/modules/system/domain/interface/index.ts +++ b/apps/server/src/modules/system/domain/interface/index.ts @@ -1 +1,2 @@ -export * from './system.repo.interface'; +export { SystemRepo, SYSTEM_REPO } from './system.repo.interface'; +export { SystemQuery } from './system-query'; diff --git a/apps/server/src/modules/system/domain/interface/system-query.ts b/apps/server/src/modules/system/domain/interface/system-query.ts new file mode 100644 index 00000000000..19c55d844d7 --- /dev/null +++ b/apps/server/src/modules/system/domain/interface/system-query.ts @@ -0,0 +1,5 @@ +import { SystemType } from '../system-type.enum'; + +export interface SystemQuery { + types?: SystemType[]; +} diff --git a/apps/server/src/modules/system/domain/interface/system.repo.interface.ts b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts index 2600c79b0dc..fdfb8bbacb1 100644 --- a/apps/server/src/modules/system/domain/interface/system.repo.interface.ts +++ b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts @@ -1,7 +1,10 @@ import { EntityId } from '@shared/domain/types/entity-id'; import { System } from '../system.do'; +import { SystemQuery } from './system-query'; export interface SystemRepo { + find(filter: SystemQuery): Promise; + getSystemsByIds(systemIds: EntityId[]): Promise; getSystemById(systemId: EntityId): Promise; diff --git a/apps/server/src/modules/system/domain/ldap-config.ts b/apps/server/src/modules/system/domain/ldap-config.ts index 137d1fc92f6..785b41ce35e 100644 --- a/apps/server/src/modules/system/domain/ldap-config.ts +++ b/apps/server/src/modules/system/domain/ldap-config.ts @@ -1,3 +1,5 @@ +import { EntityId } from '@shared/domain/types'; + export class LdapConfig { active: boolean; @@ -5,9 +7,33 @@ export class LdapConfig { provider?: string; + federalState?: EntityId; + + lastSyncAttempt?: Date; + + lastSuccessfulFullSync?: Date; + + lastSuccessfulPartialSync?: Date; + + lastModifyTimestamp?: string; + + rootPath?: string; + + searchUser?: string; + + searchUserPassword?: string; + constructor(props: LdapConfig) { this.active = props.active; this.url = props.url; this.provider = props.provider; + this.federalState = props.federalState; + this.lastSyncAttempt = props.lastSyncAttempt; + this.lastSuccessfulFullSync = props.lastSuccessfulFullSync; + this.lastSuccessfulPartialSync = props.lastSuccessfulPartialSync; + this.lastModifyTimestamp = props.lastModifyTimestamp; + this.rootPath = props.rootPath; + this.searchUser = props.searchUser; + this.searchUserPassword = props.searchUserPassword; } } diff --git a/apps/server/src/modules/system/domain/oidc-config.ts b/apps/server/src/modules/system/domain/oidc-config.ts new file mode 100644 index 00000000000..8d8dbfcc2d9 --- /dev/null +++ b/apps/server/src/modules/system/domain/oidc-config.ts @@ -0,0 +1,28 @@ +export class OidcConfig { + clientId: string; + + clientSecret: string; + + idpHint: string; + + authorizationUrl: string; + + tokenUrl: string; + + logoutUrl: string; + + userinfoUrl: string; + + defaultScopes: string; + + constructor(oauthConfigDto: OidcConfig) { + this.clientId = oauthConfigDto.clientId; + this.clientSecret = oauthConfigDto.clientSecret; + this.idpHint = oauthConfigDto.idpHint; + this.authorizationUrl = oauthConfigDto.authorizationUrl; + this.tokenUrl = oauthConfigDto.tokenUrl; + this.logoutUrl = oauthConfigDto.logoutUrl; + this.userinfoUrl = oauthConfigDto.userinfoUrl; + this.defaultScopes = oauthConfigDto.defaultScopes; + } +} diff --git a/apps/server/src/modules/system/domain/query/index.ts b/apps/server/src/modules/system/domain/query/index.ts deleted file mode 100644 index c8ad01154ee..00000000000 --- a/apps/server/src/modules/system/domain/query/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './system-query'; diff --git a/apps/server/src/modules/system/domain/query/system-query.ts b/apps/server/src/modules/system/domain/query/system-query.ts deleted file mode 100644 index 677b2f160f4..00000000000 --- a/apps/server/src/modules/system/domain/query/system-query.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EntityId } from '@shared/domain/types/entity-id'; - -export interface SystemQuery { - ids?: EntityId[]; -} diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts index 51b3e92d2c0..6255cdd32be 100644 --- a/apps/server/src/modules/system/domain/system.do.ts +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -2,6 +2,7 @@ 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'; +import { OidcConfig } from './oidc-config'; import { SystemType } from './system-type.enum'; export interface SystemProps extends AuthorizableObject { @@ -20,6 +21,8 @@ export interface SystemProps extends AuthorizableObject { oauthConfig?: OauthConfig; ldapConfig?: LdapConfig; + + oidcConfig?: OidcConfig; } export class System extends DomainObject { @@ -27,6 +30,14 @@ export class System extends DomainObject { return this.props.type; } + get alias(): string | undefined { + return this.props.alias; + } + + get displayName(): string | undefined { + return this.props.displayName; + } + get ldapConfig(): LdapConfig | undefined { return this.props.ldapConfig; } @@ -35,6 +46,18 @@ export class System extends DomainObject { return this.props.provisioningStrategy; } + get provisioningUrl(): string | undefined { + return this.props.provisioningUrl; + } + + get oauthConfig(): OauthConfig | undefined { + return this.props.oauthConfig; + } + + get oidcConfig(): OidcConfig | undefined { + return this.props.oidcConfig; + } + public isDeletable(): boolean { const isDeletable = this.ldapConfig?.provider === 'general'; diff --git a/apps/server/src/modules/system/entity/index.ts b/apps/server/src/modules/system/entity/index.ts new file mode 100644 index 00000000000..ad6f6138a38 --- /dev/null +++ b/apps/server/src/modules/system/entity/index.ts @@ -0,0 +1,7 @@ +export { + SystemEntity, + SystemEntityProps, + LdapConfigEntity, + OauthConfigEntity, + OidcConfigEntity, +} from './system.entity'; diff --git a/apps/server/src/shared/domain/entity/system.entity.spec.ts b/apps/server/src/modules/system/entity/system.entity.spec.ts similarity index 100% rename from apps/server/src/shared/domain/entity/system.entity.spec.ts rename to apps/server/src/modules/system/entity/system.entity.spec.ts diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/modules/system/entity/system.entity.ts similarity index 97% rename from apps/server/src/shared/domain/entity/system.entity.ts rename to apps/server/src/modules/system/entity/system.entity.ts index 35b84b8bcf6..d86be69e7b1 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/modules/system/entity/system.entity.ts @@ -1,8 +1,8 @@ import { Cascade, Collection, Embeddable, Embedded, Entity, Enum, OneToMany, Property } from '@mikro-orm/core'; import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { EntityId } from '../types'; -import { BaseEntityWithTimestamps } from './base.entity'; +import { EntityId } from '@shared/domain/types'; export interface SystemEntityProps { type: string; diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 5789bd88f84..e391f13df16 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -1,3 +1,13 @@ -export { LdapConfig, OauthConfig, System, SystemProps } from './domain'; -export { LegacySystemService, OauthConfigDto, OidcConfigDto, SystemDto, SystemService } from './service'; +export { + LdapConfig, + OauthConfig, + OidcConfig, + System, + SystemProps, + SYSTEM_REPO, + SystemRepo, + SystemType, + SystemDeletedEvent, +} from './domain'; +export { SystemService } from './service'; export { SystemModule } from './system.module'; diff --git a/apps/server/src/modules/system/mapper/index.ts b/apps/server/src/modules/system/mapper/index.ts deleted file mode 100644 index 9dc568e504c..00000000000 --- a/apps/server/src/modules/system/mapper/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 49c78b4dc2f..00000000000 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SystemEntity } from '@shared/domain/entity'; -import { systemEntityFactory } from '@shared/testing'; -import { SystemOidcMapper } from './system-oidc.mapper'; - -describe('SystemOidcMapper', () => { - let module: TestingModule; - afterAll(async () => { - await module.close(); - }); - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [], - providers: [SystemOidcMapper], - }).compile(); - }); - - describe('mapFromEntityToDto', () => { - it('should map all fields', () => { - const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); - - const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); - expect(result).toBeDefined(); - - expect(result?.parentSystemId).toEqual(systemEntity.id); - expect(result?.clientId).toEqual(systemEntity.oidcConfig?.clientId); - expect(result?.clientSecret).toEqual(systemEntity.oidcConfig?.clientSecret); - expect(result?.idpHint).toEqual(systemEntity.oidcConfig?.idpHint); - expect(result?.authorizationUrl).toEqual(systemEntity.oidcConfig?.authorizationUrl); - expect(result?.tokenUrl).toEqual(systemEntity.oidcConfig?.tokenUrl); - expect(result?.userinfoUrl).toEqual(systemEntity.oidcConfig?.userinfoUrl); - expect(result?.logoutUrl).toEqual(systemEntity.oidcConfig?.logoutUrl); - expect(result?.defaultScopes).toEqual(systemEntity.oidcConfig?.defaultScopes); - }); - it('should return undefined if parent system has no oidc config', () => { - const systemEntity = systemEntityFactory.withOauthConfig().build(); - const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); - expect(result).toBeUndefined(); - }); - }); - - describe('mapFromEntitiesToDtos', () => { - it('should map all given entities', () => { - const systemEntities: SystemEntity[] = [ - systemEntityFactory.withOidcConfig().build(), - systemEntityFactory.withOidcConfig().build(), - ]; - - const result = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); - - expect(result.length).toBe(systemEntities.length); - }); - - it('should map oidc config only config if exists', () => { - const systemEntity = systemEntityFactory.withOidcConfig().build(); - const systemEntities: SystemEntity[] = [systemEntity, systemEntityFactory.withOauthConfig().build()]; - - const results = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); - - expect(results.length).toBe(1); - - const [theResult] = results; - - expect(theResult.parentSystemId).toEqual(systemEntity.id); - expect(theResult.clientId).toEqual(systemEntity.oidcConfig?.clientId); - expect(theResult.clientSecret).toEqual(systemEntity.oidcConfig?.clientSecret); - expect(theResult.idpHint).toEqual(systemEntity.oidcConfig?.idpHint); - expect(theResult.authorizationUrl).toEqual(systemEntity.oidcConfig?.authorizationUrl); - expect(theResult.tokenUrl).toEqual(systemEntity.oidcConfig?.tokenUrl); - expect(theResult.userinfoUrl).toEqual(systemEntity.oidcConfig?.userinfoUrl); - expect(theResult.logoutUrl).toEqual(systemEntity.oidcConfig?.logoutUrl); - expect(theResult.defaultScopes).toEqual(systemEntity.oidcConfig?.defaultScopes); - }); - }); -}); diff --git a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts b/apps/server/src/modules/system/mapper/system-oidc.mapper.ts deleted file mode 100644 index e1a2477e2a6..00000000000 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { OidcConfigDto } from '@modules/system/service/dto/oidc-config.dto'; -import { OidcConfigEntity, SystemEntity } from '@shared/domain/entity'; - -export class SystemOidcMapper { - static mapFromEntityToDto(entity: SystemEntity): OidcConfigDto | undefined { - if (entity.oidcConfig) { - return SystemOidcMapper.mapFromOidcConfigEntityToDto(entity.id, entity.oidcConfig); - } - return undefined; - } - - static mapFromOidcConfigEntityToDto(systemId: string, oidcConfig: OidcConfigEntity): OidcConfigDto { - return new OidcConfigDto({ - parentSystemId: systemId, - clientId: oidcConfig.clientId, - clientSecret: oidcConfig?.clientSecret, - idpHint: oidcConfig.idpHint, - authorizationUrl: oidcConfig.authorizationUrl, - tokenUrl: oidcConfig.tokenUrl, - userinfoUrl: oidcConfig.userinfoUrl, - logoutUrl: oidcConfig.logoutUrl, - defaultScopes: oidcConfig.defaultScopes, - }); - } - - static mapFromEntitiesToDtos(entities: SystemEntity[]): OidcConfigDto[] { - return entities - .map((entity) => this.mapFromEntityToDto(entity)) - .filter((entity): entity is OidcConfigDto => entity !== undefined); - } -} diff --git a/apps/server/src/modules/system/mapper/system.mapper.spec.ts b/apps/server/src/modules/system/mapper/system.mapper.spec.ts deleted file mode 100644 index 02af426d04c..00000000000 --- a/apps/server/src/modules/system/mapper/system.mapper.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SystemEntity } from '@shared/domain/entity'; -import { systemEntityFactory } from '@shared/testing'; -import { SystemMapper } from './system.mapper'; - -describe('SystemMapper', () => { - let module: TestingModule; - afterAll(async () => { - await module.close(); - }); - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [], - providers: [SystemMapper], - }).compile(); - }); - - describe('mapFromEntityToDto', () => { - it('should map all fields', () => { - const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); - - const result = SystemMapper.mapFromEntityToDto(systemEntity); - - expect(result.url).toEqual(systemEntity.url); - expect(result.alias).toEqual(systemEntity.alias); - expect(result.displayName).toEqual(systemEntity.displayName); - expect(result.type).toEqual(systemEntity.type); - expect(result.provisioningStrategy).toEqual(systemEntity.provisioningStrategy); - expect(result.provisioningUrl).toEqual(systemEntity.provisioningUrl); - expect(result.oauthConfig).toEqual(systemEntity.oauthConfig); - }); - it('should map take alias as default instead of displayName', () => { - // Arrange - const systemEntity = systemEntityFactory.withOauthConfig().build(); - systemEntity.displayName = undefined; - - // Act - const result = SystemMapper.mapFromEntityToDto(systemEntity); - - // Assert - expect(result.alias).toEqual(systemEntity.alias); - expect(result.displayName).toEqual(systemEntity.alias); - }); - }); - - describe('mapFromEntitiesToDtos', () => { - it('should map all given entities', () => { - const systemEntities: SystemEntity[] = [ - systemEntityFactory.withOauthConfig().build(), - systemEntityFactory.build({ oauthConfig: undefined }), - ]; - - const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); - - expect(result.length).toBe(systemEntities.length); - }); - - it('should map oauth config if exists', () => { - const systemEntities: SystemEntity[] = [ - systemEntityFactory.withOauthConfig().build(), - systemEntityFactory.build({ oauthConfig: undefined }), - ]; - - const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); - - expect(result[0].oauthConfig?.clientId).toEqual(systemEntities[0].oauthConfig?.clientId); - expect(result[0].oauthConfig?.clientSecret).toEqual(systemEntities[0].oauthConfig?.clientSecret); - expect(result[0].oauthConfig?.grantType).toEqual(systemEntities[0].oauthConfig?.grantType); - expect(result[0].oauthConfig?.tokenEndpoint).toEqual(systemEntities[0].oauthConfig?.tokenEndpoint); - expect(result[0].oauthConfig?.authEndpoint).toEqual(systemEntities[0].oauthConfig?.authEndpoint); - expect(result[0].oauthConfig?.responseType).toEqual(systemEntities[0].oauthConfig?.responseType); - expect(result[0].oauthConfig?.scope).toEqual(systemEntities[0].oauthConfig?.scope); - expect(result[0].oauthConfig?.provider).toEqual(systemEntities[0].oauthConfig?.provider); - expect(result[0].oauthConfig?.logoutEndpoint).toEqual(systemEntities[0].oauthConfig?.logoutEndpoint); - expect(result[0].oauthConfig?.issuer).toEqual(systemEntities[0].oauthConfig?.issuer); - expect(result[0].oauthConfig?.jwksEndpoint).toEqual(systemEntities[0].oauthConfig?.jwksEndpoint); - expect(result[0].oauthConfig?.redirectUri).toEqual(systemEntities[0].oauthConfig?.redirectUri); - expect(result[1].oauthConfig).toBe(undefined); - }); - }); -}); diff --git a/apps/server/src/modules/system/mapper/system.mapper.ts b/apps/server/src/modules/system/mapper/system.mapper.ts deleted file mode 100644 index 2837d8eb912..00000000000 --- a/apps/server/src/modules/system/mapper/system.mapper.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/entity'; - -export class SystemMapper { - static mapFromEntityToDto(entity: SystemEntity): SystemDto { - return new SystemDto({ - id: entity.id, - type: entity.type, - url: entity.url, - alias: entity.alias, - displayName: entity.displayName ?? entity.alias, - provisioningStrategy: entity.provisioningStrategy, - provisioningUrl: entity.provisioningUrl, - oauthConfig: SystemMapper.mapFromOauthConfigEntityToDto(entity.oauthConfig), - ldapActive: entity.ldapConfig?.active, - }); - } - - static mapFromOauthConfigEntityToDto(oauthConfig: OauthConfigEntity | undefined): OauthConfigDto | undefined { - if (!oauthConfig) return undefined; - return new OauthConfigDto({ - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - idpHint: oauthConfig.idpHint, - redirectUri: oauthConfig.redirectUri, - grantType: oauthConfig.grantType, - tokenEndpoint: oauthConfig.tokenEndpoint, - authEndpoint: oauthConfig.authEndpoint, - responseType: oauthConfig.responseType, - scope: oauthConfig.scope, - provider: oauthConfig.provider, - logoutEndpoint: oauthConfig.logoutEndpoint, - issuer: oauthConfig.issuer, - jwksEndpoint: oauthConfig.jwksEndpoint, - }); - } - - static mapFromEntitiesToDtos(entities: SystemEntity[]): SystemDto[] { - return entities.map((entity) => this.mapFromEntityToDto(entity)); - } -} diff --git a/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts b/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts index 13fe03437e2..a8da1afa1b2 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts @@ -1,9 +1,9 @@ -import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; -import { LdapConfig, OauthConfig, System, SystemFactory } from '../../../domain'; +import { LdapConfig, OauthConfig, OidcConfig, System } from '../../../domain'; +import { LdapConfigEntity, OauthConfigEntity, OidcConfigEntity, SystemEntity } from '../../../entity'; export class SystemEntityMapper { public static mapToDo(entity: SystemEntity): System { - const system = SystemFactory.build({ + const system = new System({ id: entity.id, url: entity.url, type: entity.type, @@ -13,6 +13,7 @@ export class SystemEntityMapper { alias: entity.alias, oauthConfig: entity.oauthConfig ? this.mapOauthConfigEntityToDomainObject(entity.oauthConfig) : undefined, ldapConfig: entity.ldapConfig ? this.mapLdapConfigEntityToDomainObject(entity.ldapConfig) : undefined, + oidcConfig: entity.oidcConfig ? this.mapOidcConfigEntityToDomainObject(entity.oidcConfig) : undefined, }); return system; @@ -43,6 +44,29 @@ export class SystemEntityMapper { active: !!ldapConfig.active, url: ldapConfig.url, provider: ldapConfig.provider, + federalState: ldapConfig.federalState, + lastSyncAttempt: ldapConfig.lastSyncAttempt, + lastSuccessfulFullSync: ldapConfig.lastSuccessfulFullSync, + lastSuccessfulPartialSync: ldapConfig.lastSuccessfulPartialSync, + lastModifyTimestamp: ldapConfig.lastModifyTimestamp, + rootPath: ldapConfig.rootPath, + searchUser: ldapConfig.searchUser, + searchUserPassword: ldapConfig.searchUserPassword, + }); + + return mapped; + } + + private static mapOidcConfigEntityToDomainObject(oidcConfig: OidcConfigEntity): OidcConfig { + const mapped: OidcConfig = new OidcConfig({ + clientId: oidcConfig.clientId, + clientSecret: oidcConfig?.clientSecret, + idpHint: oidcConfig.idpHint, + authorizationUrl: oidcConfig.authorizationUrl, + tokenUrl: oidcConfig.tokenUrl, + userinfoUrl: oidcConfig.userinfoUrl, + logoutUrl: oidcConfig.logoutUrl, + defaultScopes: oidcConfig.defaultScopes, }); return mapped; diff --git a/apps/server/src/modules/system/repo/mikro-orm/scope/index.ts b/apps/server/src/modules/system/repo/mikro-orm/scope/index.ts new file mode 100644 index 00000000000..583ab6797bb --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/scope/index.ts @@ -0,0 +1 @@ +export { SystemScope } from './system.scope'; diff --git a/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.spec.ts b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.spec.ts new file mode 100644 index 00000000000..f44eab7b409 --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.spec.ts @@ -0,0 +1,46 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { EmptyResultQuery } from '@shared/repo/query'; +import { SystemType } from '../../../domain'; +import { SystemScope } from './system.scope'; + +describe(SystemScope.name, () => { + describe('byIds', () => { + describe('when filtering by ids', () => { + it('should have a query for ids', () => { + const ids = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + + const scope = new SystemScope().byIds(ids); + + expect(scope.query).toEqual({ id: { $in: ids } }); + }); + }); + + describe('when not providing ids', () => { + it('should not add a query', () => { + const scope = new SystemScope().byIds(undefined); + + expect(scope.query).toEqual(EmptyResultQuery); + }); + }); + }); + + describe('byTypes', () => { + describe('when filtering by types', () => { + it('should have a query for types', () => { + const types = [SystemType.LDAP, SystemType.OAUTH]; + + const scope = new SystemScope().byTypes(types); + + expect(scope.query).toEqual({ type: { $in: types } }); + }); + }); + + describe('when not providing types', () => { + it('should not add a query', () => { + const scope = new SystemScope().byTypes(undefined); + + expect(scope.query).toEqual(EmptyResultQuery); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts index 073d677e322..c59fc997d05 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts @@ -1,11 +1,22 @@ -import { SystemEntity } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types/entity-id'; import { Scope } from '@shared/repo/scope'; +import { SystemType } from '../../../domain'; +import { SystemEntity } from '../../../entity'; export class SystemScope extends Scope { - byIds(ids?: EntityId[]) { + byIds(ids?: EntityId[]): this { if (ids) { this.addQuery({ id: { $in: ids } }); } + + return this; + } + + byTypes(types?: SystemType[]): this { + if (types) { + this.addQuery({ type: { $in: types } }); + } + + return this; } } diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts index 157930f04fe..ba3ae03afe3 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts @@ -2,12 +2,18 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SystemTypeEnum } from '@shared/domain/types'; -import { cleanupCollections, systemEntityFactory } from '@shared/testing'; -import { SYSTEM_REPO, System, SystemProps, SystemRepo } from '../../domain'; -import { SystemEntityMapper } from './mapper/system-entity.mapper'; +import { + cleanupCollections, + systemEntityFactory, + systemLdapConfigFactory, + systemOauthConfigFactory, + systemOidcConfigFactory, +} from '@shared/testing'; +import { System, SYSTEM_REPO, SystemProps, SystemRepo, SystemType } from '../../domain'; +import { SystemEntity } from '../../entity'; +import { SystemEntityMapper } from './mapper'; import { SystemMikroOrmRepo } from './system.repo'; describe(SystemMikroOrmRepo.name, () => { @@ -33,29 +39,79 @@ describe(SystemMikroOrmRepo.name, () => { await cleanupCollections(em); }); + describe('find', () => { + describe('when no filter is provided', () => { + const setup = async () => { + const ldapSystem = systemEntityFactory.buildWithId({ type: SystemType.LDAP }); + const oauthSystem = systemEntityFactory.buildWithId({ type: SystemType.OAUTH }); + + await em.persistAndFlush([ldapSystem, oauthSystem]); + em.clear(); + + return { + ldapSystem, + oauthSystem, + }; + }; + + it('should return all systems', async () => { + const { ldapSystem, oauthSystem } = await setup(); + + const result = await repo.find({}); + + expect(result).toEqual([SystemEntityMapper.mapToDo(ldapSystem), SystemEntityMapper.mapToDo(oauthSystem)]); + }); + }); + + describe('when no system matches the filter', () => { + const setup = async () => { + const ldapSystem = systemEntityFactory.buildWithId({ type: SystemType.LDAP }); + + await em.persistAndFlush([ldapSystem]); + em.clear(); + }; + + it('should return an empty array', async () => { + await setup(); + + const result = await repo.find({ types: [SystemType.OAUTH] }); + + expect(result).toEqual([]); + }); + }); + + describe('when a system matches the filter', () => { + const setup = async () => { + const ldapSystem = systemEntityFactory.buildWithId({ type: SystemType.LDAP }); + const oauthSystem1 = systemEntityFactory.buildWithId({ type: SystemType.OAUTH }); + const oauthSystem2 = systemEntityFactory.buildWithId({ type: SystemType.OAUTH }); + + await em.persistAndFlush([ldapSystem, oauthSystem1, oauthSystem2]); + em.clear(); + + return { + oauthSystem1, + oauthSystem2, + }; + }; + + it('should return the systems', async () => { + const { oauthSystem1, oauthSystem2 } = await setup(); + + const result = await repo.find({ types: [SystemType.OAUTH] }); + + expect(result).toEqual([SystemEntityMapper.mapToDo(oauthSystem1), SystemEntityMapper.mapToDo(oauthSystem2)]); + }); + }); + }); + describe('getSystemById', () => { 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 oauthConfig = systemOauthConfigFactory.build(); + const ldapConfig = systemLdapConfigFactory.build(); + const oidcConfig = systemOidcConfigFactory.build(); + const system: SystemEntity = systemEntityFactory.buildWithId({ type: 'oauth', url: 'https://mock.de', @@ -65,6 +121,7 @@ describe(SystemMikroOrmRepo.name, () => { provisioningUrl: 'https://provisioningurl.de', oauthConfig, ldapConfig, + oidcConfig, }); await em.persistAndFlush([system]); @@ -74,11 +131,12 @@ describe(SystemMikroOrmRepo.name, () => { system, oauthConfig, ldapConfig, + oidcConfig, }; }; it('should return the system', async () => { - const { system, oauthConfig, ldapConfig } = await setup(); + const { system, oauthConfig, ldapConfig, oidcConfig } = await setup(); const result = await repo.getSystemById(system.id); @@ -110,6 +168,16 @@ describe(SystemMikroOrmRepo.name, () => { provider: ldapConfig.provider, active: !!ldapConfig.active, }, + oidcConfig: { + clientId: oidcConfig.clientId, + clientSecret: oidcConfig.clientSecret, + idpHint: oidcConfig.idpHint, + authorizationUrl: oidcConfig.authorizationUrl, + tokenUrl: oidcConfig.tokenUrl, + logoutUrl: oidcConfig.logoutUrl, + userinfoUrl: oidcConfig.userinfoUrl, + defaultScopes: oidcConfig.defaultScopes, + }, }); }); }); diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts index cfd64f8dd66..c83d82abcbf 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts @@ -1,11 +1,11 @@ import { EntityData, EntityName } from '@mikro-orm/core'; import { Injectable, NotImplementedException } from '@nestjs/common'; -import { SystemEntity } from '@shared/domain/entity'; import { EntityId, SystemTypeEnum } from '@shared/domain/types'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { System, SystemRepo } from '../../domain'; -import { SystemEntityMapper } from './mapper/system-entity.mapper'; -import { SystemScope } from './scope/system.scope'; +import { System, SystemQuery, SystemRepo } from '../../domain'; +import { SystemEntity } from '../../entity'; +import { SystemEntityMapper } from './mapper'; +import { SystemScope } from './scope'; @Injectable() export class SystemMikroOrmRepo extends BaseDomainObjectRepo implements SystemRepo { @@ -17,6 +17,16 @@ export class SystemMikroOrmRepo extends BaseDomainObjectRepo { + const scope: SystemScope = new SystemScope().byTypes(filter.types).allowEmptyQuery(true); + + const entities: SystemEntity[] = await this.em.find(SystemEntity, scope.query); + + const domainObjects: System[] = entities.map((entity: SystemEntity) => SystemEntityMapper.mapToDo(entity)); + + return domainObjects; + } + public async getSystemById(id: EntityId): Promise { const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id }); @@ -41,7 +51,6 @@ export class SystemMikroOrmRepo extends BaseDomainObjectRepo { - // Systems with an oauthConfig are filtered out here to exclude IServ. IServ is of type LDAP for syncing purposes, but the login is done via OAuth2. const entities: SystemEntity[] = await this.em.find(SystemEntity, { type: SystemTypeEnum.LDAP, ldapConfig: { active: true }, diff --git a/apps/server/src/modules/system/service/dto/index.ts b/apps/server/src/modules/system/service/dto/index.ts deleted file mode 100644 index c0a8942caa6..00000000000 --- a/apps/server/src/modules/system/service/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './oauth-config.dto'; -export * from './oidc-config.dto'; -export * from './system.dto'; diff --git a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts b/apps/server/src/modules/system/service/dto/oauth-config.dto.ts deleted file mode 100644 index 7af97200971..00000000000 --- a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts +++ /dev/null @@ -1,46 +0,0 @@ -export class OauthConfigDto { - 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: OauthConfigDto) { - 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/service/dto/oidc-config.dto.ts b/apps/server/src/modules/system/service/dto/oidc-config.dto.ts deleted file mode 100644 index 41873fd3fed..00000000000 --- a/apps/server/src/modules/system/service/dto/oidc-config.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -export class OidcConfigDto { - constructor(oidcConfigDto: OidcConfigDto) { - this.parentSystemId = oidcConfigDto.parentSystemId; - this.clientId = oidcConfigDto.clientId; - this.clientSecret = oidcConfigDto.clientSecret; - this.idpHint = oidcConfigDto.idpHint; - this.authorizationUrl = oidcConfigDto.authorizationUrl; - this.tokenUrl = oidcConfigDto.tokenUrl; - this.userinfoUrl = oidcConfigDto.userinfoUrl; - this.logoutUrl = oidcConfigDto.logoutUrl; - this.defaultScopes = oidcConfigDto.defaultScopes; - } - - parentSystemId: string; - - clientId: string; - - clientSecret: string; - - idpHint: string; - - authorizationUrl: string; - - tokenUrl: string; - - logoutUrl: string; - - userinfoUrl: string; - - defaultScopes: string; -} diff --git a/apps/server/src/modules/system/service/dto/system.dto.ts b/apps/server/src/modules/system/service/dto/system.dto.ts deleted file mode 100644 index bdffdf75423..00000000000 --- a/apps/server/src/modules/system/service/dto/system.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -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; - - type: string; - - url?: string; - - alias?: string; - - displayName?: string; - - provisioningStrategy?: SystemProvisioningStrategy; - - provisioningUrl?: string; - - oauthConfig?: OauthConfigDto; - - ldapActive?: boolean; - - constructor(system: SystemDto) { - this.id = system.id; - this.type = system.type; - this.url = system.url; - this.alias = system.alias; - this.displayName = system.displayName; - this.provisioningStrategy = system.provisioningStrategy; - this.provisioningUrl = system.provisioningUrl; - this.oauthConfig = system.oauthConfig; - this.ldapActive = system.ldapActive; - } -} diff --git a/apps/server/src/modules/system/service/index.ts b/apps/server/src/modules/system/service/index.ts index 6be1d3fb0fa..3bc23ee212e 100644 --- a/apps/server/src/modules/system/service/index.ts +++ b/apps/server/src/modules/system/service/index.ts @@ -1,4 +1 @@ -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 deleted file mode 100644 index c1528da8ead..00000000000 --- a/apps/server/src/modules/system/service/legacy-system.service.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -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 } from '@shared/domain/entity'; -import { SystemTypeEnum } from '@shared/domain/types'; -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 deleted file mode 100644 index 870219dd92c..00000000000 --- a/apps/server/src/modules/system/service/legacy-system.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { IdentityManagementOauthService } from '@infra/identity-management'; -import { Injectable } from '@nestjs/common'; -import { EntityNotFoundError } from '@shared/common'; -import { SystemEntity } from '@shared/domain/entity'; -import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -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 deleted file mode 100644 index 6c20f4c7958..00000000000 --- a/apps/server/src/modules/system/service/system-oidc.service.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { SystemEntity } from '@shared/domain/entity'; -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; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - SystemOidcService, - { - provide: LegacySystemRepo, - useValue: createMock(), - }, - ], - }).compile(); - systemRepoMock = module.get(LegacySystemRepo); - systemService = module.get(SystemOidcService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findById', () => { - const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); - const standaloneSystem = systemEntityFactory.buildWithId({ alias: 'standaloneSystem' }); - const setup = (system: SystemEntity) => { - systemRepoMock.findById.mockResolvedValue(system); - }; - - it('should return a found oidc system', async () => { - setup(oidcSystem); - const result = await systemService.findById('someMockedId'); - expect(result).toStrictEqual(SystemOidcMapper.mapFromEntityToDto(oidcSystem)); - }); - - it('should throw if system does not contain a oidc config', async () => { - setup(standaloneSystem); - await expect(systemService.findById('someMockedId')).rejects.toThrow(EntityNotFoundError); - }); - }); - - describe('findAll', () => { - 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]); - const result = await systemService.findAll(); - expect(result).toEqual(expect.arrayContaining(SystemOidcMapper.mapFromEntitiesToDtos([oidcSystem]))); - }); - - it('should return empty list if no oidc system exists', async () => { - systemRepoMock.findByFilter.mockResolvedValue([ldapSystem, oauthSystem]); - const result = await systemService.findAll(); - expect(result).toHaveLength(0); - }); - }); -}); diff --git a/apps/server/src/modules/system/service/system-oidc.service.ts b/apps/server/src/modules/system/service/system-oidc.service.ts deleted file mode 100644 index 6a0e7651bab..00000000000 --- a/apps/server/src/modules/system/service/system-oidc.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityNotFoundError } from '@shared/common'; -import { SystemEntity } from '@shared/domain/entity'; -import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -import { LegacySystemRepo } from '@shared/repo'; -import { SystemOidcMapper } from '../mapper'; -import { OidcConfigDto } from './dto'; - -@Injectable() -export class SystemOidcService { - constructor(private readonly systemRepo: LegacySystemRepo) {} - - async findById(id: EntityId): Promise { - const system = await this.systemRepo.findById(id); - const mappedEntity = SystemOidcMapper.mapFromEntityToDto(system); - if (!mappedEntity) { - throw new EntityNotFoundError(SystemEntity.name, { id }); - } - return mappedEntity; - } - - async findAll(): Promise<[] | OidcConfigDto[]> { - const system = await this.systemRepo.findByFilter(SystemTypeEnum.OIDC); - return SystemOidcMapper.mapFromEntitiesToDtos(system); - } -} 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 5c1eec8f486..5c835580837 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -1,8 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { systemFactory } from '@shared/testing'; -import { SYSTEM_REPO, SystemRepo } from '../domain'; +import { SYSTEM_REPO, SystemQuery, SystemRepo, SystemType } from '../domain'; import { SystemService } from './system.service'; describe(SystemService.name, () => { @@ -34,6 +35,36 @@ describe(SystemService.name, () => { jest.resetAllMocks(); }); + describe('find', () => { + describe('when searching systems with filter', () => { + const setup = () => { + const systems = systemFactory.buildList(1, { type: SystemType.OAUTH }); + + systemRepo.find.mockResolvedValueOnce(systems); + + return { + systems, + }; + }; + + it('should call the repo', async () => { + setup(); + + await service.find({ types: [SystemType.OAUTH] }); + + expect(systemRepo.find).toHaveBeenCalledWith<[SystemQuery]>({ types: [SystemType.OAUTH] }); + }); + + it('should return the systems', async () => { + const { systems } = setup(); + + const result = await service.find({ types: [SystemType.OAUTH] }); + + expect(result).toEqual(systems); + }); + }); + }); + describe('findById', () => { describe('when the system exists', () => { const setup = () => { @@ -70,6 +101,40 @@ describe(SystemService.name, () => { }); }); + describe('findByIdOrFail', () => { + describe('when the system exists', () => { + const setup = () => { + const system = systemFactory.build(); + + systemRepo.getSystemById.mockResolvedValueOnce(system); + + return { + system, + }; + }; + + it('should return the system', async () => { + const { system } = setup(); + + const result = await service.findByIdOrFail(system.id); + + expect(result).toEqual(system); + }); + }); + + describe('when the system does not exist', () => { + const setup = () => { + systemRepo.getSystemById.mockResolvedValueOnce(null); + }; + + it('should return null', async () => { + setup(); + + await expect(service.findByIdOrFail(new ObjectId().toHexString())).rejects.toThrow(NotFoundLoggableException); + }); + }); + }); + describe('getSystems', () => { describe('when systems exist', () => { const setup = () => { @@ -122,6 +187,36 @@ describe(SystemService.name, () => { }); }); + describe('findAllForLdapLogin', () => { + describe('when searching for login ldap systems', () => { + const setup = () => { + const systems = systemFactory.buildList(1, { type: SystemType.LDAP }); + + systemRepo.findAllForLdapLogin.mockResolvedValueOnce(systems); + + return { + systems, + }; + }; + + it('should call the repo', async () => { + setup(); + + await service.findAllForLdapLogin(); + + expect(systemRepo.findAllForLdapLogin).toHaveBeenCalledWith(); + }); + + it('should return the systems', async () => { + const { systems } = setup(); + + const result = await service.findAllForLdapLogin(); + + expect(result).toEqual(systems); + }); + }); + }); + describe('delete', () => { describe('when the system was deleted', () => { const setup = () => { diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index 590ad227868..4b83558cac9 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -1,25 +1,42 @@ import { Inject, Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { EntityId } from '@shared/domain/types'; -import { SYSTEM_REPO, System, SystemRepo } from '../domain'; +import { System, SYSTEM_REPO, SystemQuery, SystemRepo } from '../domain'; @Injectable() export class SystemService { constructor(@Inject(SYSTEM_REPO) private readonly systemRepo: SystemRepo) {} + async find(filter: SystemQuery): Promise { + const systems: System[] = await this.systemRepo.find(filter); + + return systems; + } + public async findById(id: EntityId): Promise { - const system = await this.systemRepo.getSystemById(id); + const system: System | null = await this.systemRepo.getSystemById(id); + + return system; + } + + public async findByIdOrFail(id: EntityId): Promise { + const system: System | null = await this.systemRepo.getSystemById(id); + + if (!system) { + throw new NotFoundLoggableException(System.name, { id }); + } return system; } public async getSystems(id: EntityId[]): Promise { - const systems = await this.systemRepo.getSystemsByIds(id); + const systems: System[] = await this.systemRepo.getSystemsByIds(id); return systems; } public async findAllForLdapLogin(): Promise { - const systems = await this.systemRepo.findAllForLdapLogin(); + const systems: System[] = await this.systemRepo.findAllForLdapLogin(); return systems; } diff --git a/apps/server/src/modules/system/system-api.module.ts b/apps/server/src/modules/system/system-api.module.ts index 54592a5c4e6..a2c12f98733 100644 --- a/apps/server/src/modules/system/system-api.module.ts +++ b/apps/server/src/modules/system/system-api.module.ts @@ -1,12 +1,12 @@ import { AuthorizationModule } from '@modules/authorization'; -import { LegacySchoolModule } from '@modules/legacy-school'; -import { SystemController } from '@modules/system/controller/system.controller'; -import { SystemUc } from '@modules/system/uc/system.uc'; import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { SystemController } from './controller'; import { SystemModule } from './system.module'; +import { SystemUc } from './uc/system.uc'; @Module({ - imports: [SystemModule, AuthorizationModule, LegacySchoolModule], + imports: [CqrsModule, 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 e2d358fb061..7fa512e528f 100644 --- a/apps/server/src/modules/system/system.module.ts +++ b/apps/server/src/modules/system/system.module.ts @@ -1,20 +1,12 @@ import { IdentityManagementModule } from '@infra/identity-management/identity-management.module'; import { Module } from '@nestjs/common'; -import { LegacySystemRepo } from '@shared/repo'; import { SYSTEM_REPO } from './domain'; -import { SystemMikroOrmRepo } from './repo/mikro-orm/system.repo'; -import { LegacySystemService, SystemService } from './service'; -import { SystemOidcService } from './service/system-oidc.service'; +import { SystemMikroOrmRepo } from './repo'; +import { SystemService } from './service'; @Module({ imports: [IdentityManagementModule], - providers: [ - LegacySystemRepo, - LegacySystemService, - SystemOidcService, - SystemService, - { provide: SYSTEM_REPO, useClass: SystemMikroOrmRepo }, - ], - exports: [LegacySystemService, SystemOidcService, SystemService], + providers: [SystemService, { provide: SYSTEM_REPO, useClass: SystemMikroOrmRepo }], + exports: [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 80202175beb..088a219e90e 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,34 +1,22 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; import { SystemUc } from '@modules/system/uc/system.uc'; +import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { SystemEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -import { legacySchoolDoFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; -import { SystemType } from '../domain'; -import { SystemMapper } from '../mapper'; -import { LegacySystemService, SystemService } from '../service'; +import { setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { SystemDeletedEvent, SystemQuery, SystemType } from '../domain'; +import { SystemService } from '../service'; describe('SystemUc', () => { let module: TestingModule; let systemUc: SystemUc; - let mockSystem1: SystemDto; - let mockSystem2: SystemDto; - let mockSystems: SystemDto[]; - let system1: SystemEntity; - let system2: SystemEntity; - let legacySystemService: DeepMocked; let systemService: DeepMocked; let authorizationService: DeepMocked; - let schoolService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -36,10 +24,6 @@ describe('SystemUc', () => { module = await Test.createTestingModule({ providers: [ SystemUc, - { - provide: LegacySystemService, - useValue: createMock(), - }, { provide: SystemService, useValue: createMock(), @@ -49,17 +33,16 @@ describe('SystemUc', () => { useValue: createMock(), }, { - provide: LegacySchoolService, - useValue: createMock(), + provide: EventBus, + useValue: createMock(), }, ], }).compile(); systemUc = module.get(SystemUc); - legacySystemService = module.get(LegacySystemService); systemService = module.get(SystemService); authorizationService = module.get(AuthorizationService); - schoolService = module.get(LegacySchoolService); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -70,97 +53,126 @@ describe('SystemUc', () => { jest.clearAllMocks(); }); - describe('findByFilter', () => { - beforeEach(() => { - system1 = systemEntityFactory.buildWithId(); - system2 = systemEntityFactory.buildWithId(); + describe('find', () => { + describe('when no query is provided', () => { + const setup = () => { + const oauthSystem = systemFactory.withOauthConfig().build(); + const ldapSystem = systemFactory.withLdapConfig({ active: true }).build(); + const deactivatedLdapSystem = systemFactory.withLdapConfig({ active: false }).build(); + + systemService.find.mockResolvedValueOnce([oauthSystem, ldapSystem, deactivatedLdapSystem]); + + return { + oauthSystem, + ldapSystem, + deactivatedLdapSystem, + }; + }; + + it('should find all systems', async () => { + setup(); - mockSystem1 = SystemMapper.mapFromEntityToDto(system1); - mockSystem2 = SystemMapper.mapFromEntityToDto(system2); - mockSystems = [mockSystem1, mockSystem2]; + await systemUc.find(); - legacySystemService.findByType.mockImplementation((type: string | undefined) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); - return Promise.resolve(mockSystems); + expect(systemService.find).toHaveBeenCalledWith({}); }); - 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(); + it('should return all active systems', async () => { + const { oauthSystem, ldapSystem } = setup(); + + const result = await systemUc.find(); - expect(systems.length).toEqual(mockSystems.length); - expect(systems).toContainEqual(expect.objectContaining({ alias: system1.alias })); - expect(systems).toContainEqual(expect.objectContaining({ alias: system2.alias })); + expect(result).toEqual([oauthSystem, ldapSystem]); + }); }); - it('should return specified systems by type', async () => { - const systems: SystemDto[] = await systemUc.findByFilter(SystemTypeEnum.OAUTH); + describe('when types are provided', () => { + const setup = () => { + const ldapSystem = systemFactory.withLdapConfig({ active: true }).build(); + const deactivatedLdapSystem = systemFactory.withLdapConfig({ active: false }).build(); - expect(systems.length).toEqual(1); - expect(systems[0].oauthConfig?.clientId).toEqual(system1.oauthConfig?.clientId); - }); + systemService.find.mockResolvedValueOnce([ldapSystem, deactivatedLdapSystem]); - it('should return oauth systems if requested', async () => { - const systems: SystemDto[] = await systemUc.findByFilter(undefined, true); + return { + ldapSystem, + deactivatedLdapSystem, + }; + }; - expect(systems.length).toEqual(1); - expect(systems[0].oauthConfig?.clientId).toEqual(system2.oauthConfig?.clientId); - }); + it('should find all systems of this type', async () => { + setup(); - it('should return empty system list, because none exist', async () => { - legacySystemService.findByType.mockResolvedValue([]); - const resultResponse = await systemUc.findByFilter(); - expect(resultResponse).toHaveLength(0); + await systemUc.find([SystemType.LDAP]); + + expect(systemService.find).toHaveBeenCalledWith<[SystemQuery]>({ types: [SystemType.LDAP] }); + }); + + it('should return all active systems of this type', async () => { + const { ldapSystem } = setup(); + + const result = await systemUc.find(); + + expect(result).toEqual([ldapSystem]); + }); }); }); describe('findById', () => { - beforeEach(() => { - system1 = systemEntityFactory.buildWithId(); - system2 = systemEntityFactory.buildWithId(); + describe('when a system with the id exists', () => { + const setup = () => { + const system = systemFactory.withOauthConfig().build(); - mockSystem1 = SystemMapper.mapFromEntityToDto(system1); - mockSystem2 = SystemMapper.mapFromEntityToDto(system2); - mockSystems = [mockSystem1, mockSystem2]; + systemService.findById.mockResolvedValueOnce(system); - legacySystemService.findByType.mockImplementation((type: string | undefined) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); - return Promise.resolve(mockSystems); + return { + system, + }; + }; + + it('should find the system by id', async () => { + const { system } = setup(); + + await systemUc.findById(system.id); + + expect(systemService.findById).toHaveBeenCalledWith(system.id); }); - 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); + it('should return the system', async () => { + const { system } = setup(); - expect(receivedSystem.alias).toEqual(system1.alias); - }); + const result = await systemUc.findById(system.id); - it('should reject promise, because no entity was found', async () => { - await expect(systemUc.findById('unknown id')).rejects.toEqual(undefined); + expect(result).toEqual(system); + }); }); - describe('when the ldap is not active', () => { + describe('when no system with the id exists', () => { const setup = () => { - const system: SystemDto = new SystemDto({ - ldapActive: false, - type: 'ldap', - }); - - legacySystemService.findById.mockResolvedValue(system); + systemService.findById.mockResolvedValueOnce(null); }; - it('should reject promise, because ldap is not active', async () => { + it('should throw an error', async () => { setup(); - const func = async () => systemUc.findById('id'); + await expect(systemUc.findById(new ObjectId().toHexString())).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the system is a deactivated ldap system', () => { + const setup = () => { + const system = systemFactory.withLdapConfig({ active: false }).build(); - await expect(func).rejects.toThrow(EntityNotFoundError); + systemService.findById.mockResolvedValueOnce(system); + + return { + system, + }; + }; + + it('should throw an error', async () => { + const { system } = setup(); + + await expect(systemUc.findById(system.id)).rejects.toThrow(NotFoundLoggableException); }); }); }); @@ -170,22 +182,13 @@ describe('SystemUc', () => { const setup = () => { const user = userFactory.buildWithId(); const system = systemFactory.build(); - const otherSystemId = new ObjectId().toHexString(); - const school = legacySchoolDoFactory.build({ - systems: [system.id, otherSystemId], - ldapLastSync: new Date().toString(), - externalId: 'test', - }); systemService.findById.mockResolvedValueOnce(system); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, system, - school, - otherSystemId, }; }; @@ -210,50 +213,11 @@ describe('SystemUc', () => { }); it('should remove the system from the school', async () => { - const { user, system, school, otherSystemId } = setup(); - - await systemUc.delete(user.id, user.school.id, system.id); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - systems: [otherSystemId], - ldapLastSync: undefined, - externalId: school.externalId, - }) - ); - }); - }); - - describe('when the system is the last ldap system at the school', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const system = systemFactory.build({ type: SystemType.LDAP }); - const school = legacySchoolDoFactory.build({ - systems: [system.id], - ldapLastSync: new Date().toString(), - externalId: 'test', - }); - - systemService.findById.mockResolvedValueOnce(system); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); - - return { - user, - system, - }; - }; - - it('should remove the external id of the school', async () => { const { user, system } = setup(); await systemUc.delete(user.id, user.school.id, system.id); - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - externalId: undefined, - }) - ); + expect(eventBus.publish).toHaveBeenCalledWith(new SystemDeletedEvent({ schoolId: user.school.id, system })); }); }); diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 9a13ad77214..01e69469b28 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -1,43 +1,34 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common'; -import { EntityNotFoundError } from '@shared/common'; +import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { SystemEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -import { System, SystemType } from '../domain'; -import { LegacySystemService, SystemDto, SystemService } from '../service'; +import { EntityId } from '@shared/domain/types'; +import { System, SystemDeletedEvent, SystemType } from '../domain'; +import { SystemService } from '../service'; @Injectable() export class SystemUc { constructor( - private readonly legacySystemService: LegacySystemService, private readonly systemService: SystemService, private readonly authorizationService: AuthorizationService, - private readonly schoolService: LegacySchoolService + private readonly eventBus: EventBus ) {} - async findByFilter(type?: SystemTypeEnum, onlyOauth = false): Promise { - let systems: SystemDto[]; + async find(types?: SystemType[]): Promise { + let systems: System[] = await this.systemService.find({ types }); - if (onlyOauth) { - systems = await this.legacySystemService.findByType(SystemTypeEnum.OAUTH); - } else { - systems = await this.legacySystemService.findByType(type); - } - - systems = systems.filter((system: SystemDto) => system.ldapActive !== false); + systems = systems.filter((system: System) => system.ldapConfig?.active !== false); return systems; } - async findById(id: EntityId): Promise { - const system: SystemDto = await this.legacySystemService.findById(id); + async findById(systemId: EntityId): Promise { + const system: System | null = await this.systemService.findById(systemId); - if (system.ldapActive === false) { - throw new EntityNotFoundError(SystemEntity.name, { id }); + if (!system || system.ldapConfig?.active === false) { + throw new NotFoundLoggableException(System.name, { id: systemId }); } return system; @@ -59,19 +50,6 @@ export class SystemUc { await this.systemService.delete(system); - await this.removeSystemFromSchool(schoolId, system); - } - - private async removeSystemFromSchool(schoolId: string, system: System) { - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); - - school.systems = school.systems?.filter((schoolSystemId: string) => schoolSystemId !== system.id); - school.ldapLastSync = undefined; - - if (system.type === SystemType.LDAP && school.systems?.length === 0) { - school.externalId = undefined; - } - - await this.schoolService.save(school); + await this.eventBus.publish(new SystemDeletedEvent({ schoolId, system })); } } diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index b0a017dd59c..534ecbc06aa 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -17,6 +17,7 @@ export interface TldrawConfig { API_HOST: number; TLDRAW_MAX_DOCUMENT_SIZE: number; TLDRAW_FINALIZE_DELAY: number; + PERFORMANCE_MEASURE_ENABLED: boolean; } export const TLDRAW_DB_URL: string = Configuration.get('TLDRAW_DB_URL') as string; @@ -24,7 +25,7 @@ export const TLDRAW_SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as nu const tldrawConfig = { TLDRAW_DB_URL, - NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + NEST_LOG_LEVEL: Configuration.get('TLDRAW__LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, TLDRAW_DB_COMPRESS_THRESHOLD: Configuration.get('TLDRAW__DB_COMPRESS_THRESHOLD') as number, FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, @@ -39,6 +40,7 @@ const tldrawConfig = { API_HOST: Configuration.get('API_HOST') as string, TLDRAW_MAX_DOCUMENT_SIZE: Configuration.get('TLDRAW__MAX_DOCUMENT_SIZE') as number, TLDRAW_FINALIZE_DELAY: Configuration.get('TLDRAW__FINALIZE_DELAY') as number, + PERFORMANCE_MEASURE_ENABLED: Configuration.get('TLDRAW__PERFORMANCE_MEASURE_ENABLED') as boolean, }; export const config = () => tldrawConfig; diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts index a3a2ae88677..dbdd475a32f 100644 --- a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts @@ -1,17 +1,17 @@ +import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; +import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { ConfigModule } from '@nestjs/config'; -import { createMock } from '@golevelup/ts-jest'; -import * as Yjs from 'yjs'; import { createConfigModuleOptions } from '@src/config'; import { DomainErrorHandler } from '@src/core'; -import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; +import * as Yjs from 'yjs'; import { TldrawDrawing } from '../entities'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; +import { Version } from './key.factory'; import { TldrawRepo } from './tldraw.repo'; import { YMongodb } from './y-mongodb'; -import { Version } from './key.factory'; jest.mock('yjs', () => { const moduleMock: unknown = { diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.ts index 1ff357bba1c..edc7fae12fc 100644 --- a/apps/server/src/modules/tldraw/repo/y-mongodb.ts +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.ts @@ -1,12 +1,12 @@ import { BulkWriteResult } from '@mikro-orm/mongodb/node_modules/mongodb'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { DomainErrorHandler } from '@src/core'; import { Buffer } from 'buffer'; import * as binary from 'lib0/binary'; import * as encoding from 'lib0/encoding'; import * as promise from 'lib0/promise'; import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector, mergeUpdates } from 'yjs'; -import { DomainErrorHandler } from '@src/core'; import { TldrawConfig } from '../config'; import { WsSharedDocDo } from '../domain'; import { TldrawDrawing } from '../entities'; @@ -94,7 +94,8 @@ export class YMongodb { // return value is not void, need to be changed public compressDocumentTransactional(docName: string): Promise { - // return value can be null, need to be defined + performance.mark('compressDocumentTransactional'); + return this._transact(docName, async () => { const updates = await this.getMongoUpdates(docName); const mergedUpdates = mergeUpdates(updates); @@ -105,10 +106,16 @@ export class YMongodb { const stateAsUpdate = encodeStateAsUpdate(ydoc); const sv = encodeStateVector(ydoc); const clock = await this.storeUpdate(docName, stateAsUpdate); + await this.writeStateVector(docName, sv, clock); await this.clearUpdatesRange(docName, 0, clock); ydoc.destroy(); + + performance.measure('tldraw:YMongodb:compressDocumentTransactional', { + start: 'compressDocumentTransactional', + detail: { doc_name: docName, clock }, + }); }); } @@ -143,20 +150,24 @@ export class YMongodb { return this.repo.readAsCursor(query, opts); } - private mergeDocsTogether(doc: TldrawDrawing, docs: TldrawDrawing[], docIndex: number): Buffer[] { - const parts = [Buffer.from(doc.value.buffer)]; - let currentPartId: number | undefined = doc.part; - for (let i = docIndex + 1; i < docs.length; i += 1) { - const part = docs[i]; - - if (!this.isSameClock(part, doc)) { + private mergeDocsTogether( + tldrawDrawingEntity: TldrawDrawing, + tldrawDrawingEntities: TldrawDrawing[], + docIndex: number + ): Buffer[] { + const parts = [Buffer.from(tldrawDrawingEntity.value.buffer)]; + let currentPartId: number | undefined = tldrawDrawingEntity.part; + for (let i = docIndex + 1; i < tldrawDrawingEntities.length; i += 1) { + const entity = tldrawDrawingEntities[i]; + + if (!this.isSameClock(entity, tldrawDrawingEntity)) { break; } - this.checkIfPartIsNextPartAfterCurrent(part, currentPartId); + this.checkIfPartIsNextPartAfterCurrent(entity, currentPartId); - parts.push(Buffer.from(part.value.buffer)); - currentPartId = part.part; + parts.push(Buffer.from(entity.value.buffer)); + currentPartId = entity.part; } return parts; @@ -165,20 +176,20 @@ export class YMongodb { /** * Convert the mongo document array to an array of values (as buffers) */ - private convertMongoUpdates(docs: TldrawDrawing[]): Buffer[] { - if (!Array.isArray(docs) || !docs.length) return []; + private convertMongoUpdates(tldrawDrawingEntities: TldrawDrawing[]): Buffer[] { + if (!Array.isArray(tldrawDrawingEntities) || !tldrawDrawingEntities.length) return []; const updates: Buffer[] = []; - for (let i = 0; i < docs.length; i += 1) { - const doc = docs[i]; + for (let i = 0; i < tldrawDrawingEntities.length; i += 1) { + const tldrawDrawingEntity = tldrawDrawingEntities[i]; - if (!doc.part) { - updates.push(Buffer.from(doc.value.buffer)); + if (!tldrawDrawingEntity.part) { + updates.push(Buffer.from(tldrawDrawingEntity.value.buffer)); } - if (doc.part === 1) { + if (tldrawDrawingEntity.part === 1) { // merge the docs together that got split because of mongodb size limits - const parts = this.mergeDocsTogether(doc, docs, i); + const parts = this.mergeDocsTogether(tldrawDrawingEntity, tldrawDrawingEntities, i); updates.push(Buffer.concat(parts)); } } @@ -189,10 +200,19 @@ export class YMongodb { * Get all document updates for a specific document. */ private async getMongoUpdates(docName: string, opts = {}): Promise { + performance.mark('getMongoUpdates'); + const uniqueKey = KeyFactory.createForUpdate(docName); - const docs = await this.getMongoBulkData(uniqueKey, opts); + const tldrawDrawingEntities = await this.getMongoBulkData(uniqueKey, opts); + + const buffer = this.convertMongoUpdates(tldrawDrawingEntities); + + performance.measure('tldraw:YMongodb:getMongoUpdates', { + start: 'getMongoUpdates', + detail: { doc_name: docName, loaded_tldraw_entities_total: tldrawDrawingEntities.length }, + }); - return this.convertMongoUpdates(docs); + return buffer; } private async writeStateVector(docName: string, sv: Uint8Array, clock: number): Promise { @@ -247,20 +267,23 @@ export class YMongodb { return clock + 1; } - private isSameClock(doc1: TldrawDrawing, doc2: TldrawDrawing): boolean { - return doc1.clock === doc2.clock; + private isSameClock(tldrawDrawingEntity1: TldrawDrawing, tldrawDrawingEntity2: TldrawDrawing): boolean { + return tldrawDrawingEntity1.clock === tldrawDrawingEntity2.clock; } - private checkIfPartIsNextPartAfterCurrent(part: TldrawDrawing, currentPartId: number | undefined): void { - if (part.part === undefined || currentPartId !== part.part - 1) { + private checkIfPartIsNextPartAfterCurrent( + tldrawDrawingEntity: TldrawDrawing, + currentPartId: number | undefined + ): void { + if (tldrawDrawingEntity.part === undefined || currentPartId !== tldrawDrawingEntity.part - 1) { throw new Error('Could not merge updates together because a part is missing'); } } - private extractClock(updates: TldrawDrawing[]): number { - if (updates.length === 0 || updates[0].clock == null) { + private extractClock(tldrawDrawingEntities: TldrawDrawing[]): number { + if (tldrawDrawingEntities.length === 0 || tldrawDrawingEntities[0].clock == null) { return -1; } - return updates[0].clock; + return tldrawDrawingEntities[0].clock; } } diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts index 50b19dca282..61853806003 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -1,29 +1,29 @@ -import { Test } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { HttpService } from '@nestjs/axios'; import { INestApplication } from '@nestjs/common'; -import WebSocket from 'ws'; +import { ConfigModule } from '@nestjs/config'; import { WsAdapter } from '@nestjs/platform-ws'; -import { TextEncoder } from 'util'; -import * as Yjs from 'yjs'; -import * as SyncProtocols from 'y-protocols/sync'; -import * as AwarenessProtocol from 'y-protocols/awareness'; -import * as Ioredis from 'ioredis'; -import { encoding } from 'lib0'; -import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; -import { HttpService } from '@nestjs/axios'; +import { Test } from '@nestjs/testing'; import { WebSocketReadyStateEnum } from '@shared/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ConfigModule } from '@nestjs/config'; +import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; import { createConfigModuleOptions } from '@src/config'; -import { MongoMemoryDatabaseModule } from '@infra/database'; import { DomainErrorHandler } from '@src/core'; -import { TldrawRedisFactory, TldrawRedisService } from '../redis'; +import * as Ioredis from 'ioredis'; +import { encoding } from 'lib0'; +import { TextEncoder } from 'util'; +import WebSocket from 'ws'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import * as SyncProtocols from 'y-protocols/sync'; +import * as Yjs from 'yjs'; +import { TldrawWsService } from '.'; import { TldrawWs } from '../controller'; +import { WsSharedDocDo } from '../domain'; import { TldrawDrawing } from '../entities'; +import { MetricsService } from '../metrics'; +import { TldrawRedisFactory, TldrawRedisService } from '../redis'; import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../repo'; import { TestConnection, tldrawTestConfig } from '../testing'; -import { WsSharedDocDo } from '../domain'; -import { MetricsService } from '../metrics'; -import { TldrawWsService } from '.'; jest.mock('yjs', () => { const moduleMock: unknown = { diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts index 82deaf6ac3c..1fadacf0bfd 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -1,31 +1,23 @@ import { Injectable, NotAcceptableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { DomainErrorHandler } from '@src/core'; +import { decoding, encoding } from 'lib0'; +import { Buffer } from 'node:buffer'; import WebSocket from 'ws'; import { encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; -import { decoding, encoding } from 'lib0'; import { readSyncMessage, writeSyncStep1, writeSyncStep2, writeUpdate } from 'y-protocols/sync'; -import { Buffer } from 'node:buffer'; -import { YMap } from 'yjs/dist/src/types/YMap'; -import { DomainErrorHandler } from '@src/core'; -import { TldrawRedisService } from '../redis'; +import { TldrawConfig } from '../config'; +import { WsSharedDocDo } from '../domain'; import { CloseConnectionLoggable, WebsocketErrorLoggable, WebsocketMessageErrorLoggable, WsSharedDocErrorLoggable, } from '../loggable'; -import { TldrawConfig } from '../config'; -import { - AwarenessConnectionsUpdate, - TldrawAsset, - TldrawShape, - UpdateOrigin, - UpdateType, - WSMessageType, -} from '../types'; -import { WsSharedDocDo } from '../domain'; -import { TldrawBoardRepo } from '../repo'; import { MetricsService } from '../metrics'; +import { TldrawRedisService } from '../redis'; +import { TldrawBoardRepo } from '../repo'; +import { AwarenessConnectionsUpdate, UpdateOrigin, UpdateType, WSMessageType } from '../types'; @Injectable() export class TldrawWsService { @@ -42,6 +34,8 @@ export class TldrawWsService { } public async closeConnection(doc: WsSharedDocDo, ws: WebSocket): Promise { + performance.mark('closeConnection'); + if (doc.connections.has(ws)) { const controlledIds = doc.connections.get(ws); doc.connections.delete(ws); @@ -52,6 +46,11 @@ export class TldrawWsService { ws.close(); await this.finalizeIfNoConnections(doc); + + performance.measure('tldraw:TldrawWsService:closeConnection', { + start: 'closeConnection', + detail: { doc_name: doc.name, doc_connection_total: doc.connections.size }, + }); } public send(doc: WsSharedDocDo, ws: WebSocket, message: Uint8Array): void { @@ -179,6 +178,8 @@ export class TldrawWsService { }; public async setupWsConnection(ws: WebSocket, docName: string): Promise { + performance.mark('setupWsConnection'); + ws.binaryType = 'arraybuffer'; // get doc, initialize if it does not exist yet - update this.getDocument(docName) can be return null @@ -224,27 +225,36 @@ export class TldrawWsService { pongReceived = true; }); - { - // send initial doc state to client as update - this.sendInitialState(ws, doc); - - const syncEncoder = encoding.createEncoder(); - encoding.writeVarUint(syncEncoder, WSMessageType.SYNC); - writeSyncStep1(syncEncoder, doc); - this.send(doc, ws, encoding.toUint8Array(syncEncoder)); - - const awarenessStates = doc.awareness.getStates(); - if (awarenessStates.size > 0) { - const awarenessEncoder = encoding.createEncoder(); - encoding.writeVarUint(awarenessEncoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array( - awarenessEncoder, - encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) - ); - this.send(doc, ws, encoding.toUint8Array(awarenessEncoder)); - } + // send initial doc state to client as update + this.sendInitialState(ws, doc); + + const syncEncoder = encoding.createEncoder(); + encoding.writeVarUint(syncEncoder, WSMessageType.SYNC); + writeSyncStep1(syncEncoder, doc); + this.send(doc, ws, encoding.toUint8Array(syncEncoder)); + + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array( + awarenessEncoder, + encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) + ); + this.send(doc, ws, encoding.toUint8Array(awarenessEncoder)); } + this.metricsService.incrementNumberOfUsersOnServerCounter(); + + performance.measure('tldraw:TldrawWsService:setupWsConnection', { + start: 'setupWsConnection', + detail: { + doc_name: doc.name, + doc_awareness_state_total: awarenessStates.size, + doc_connection_total: doc.connections.size, + pod_docs_total: this.docs.size, + }, + }); } private async finalizeIfNoConnections(doc: WsSharedDocDo) { @@ -274,34 +284,6 @@ export class TldrawWsService { } } - private syncDocumentAssetsWithShapes(doc: WsSharedDocDo): TldrawAsset[] { - // clean up assets that are not used as shapes anymore - // which can happen when users do undo/redo operations on assets - const assets: YMap = doc.getMap('assets'); - const shapes: YMap = doc.getMap('shapes'); - const usedShapesAsAssets: TldrawShape[] = []; - const usedAssets: TldrawAsset[] = []; - - for (const [, shape] of shapes) { - if (shape.assetId) { - usedShapesAsAssets.push(shape); - } - } - - doc.transact(() => { - for (const [, asset] of assets) { - const foundAsset = usedShapesAsAssets.some((shape) => shape.assetId === asset.id); - if (!foundAsset) { - assets.delete(asset.id); - } else { - usedAssets.push(asset); - } - } - }); - - return usedAssets; - } - private sendUpdateToConnectedClients(update: Uint8Array, doc: WsSharedDocDo): void { const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, WSMessageType.SYNC); diff --git a/apps/server/src/modules/tldraw/tldraw-console.module.ts b/apps/server/src/modules/tldraw/tldraw-console.module.ts index 171505bd510..80cddece592 100644 --- a/apps/server/src/modules/tldraw/tldraw-console.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-console.module.ts @@ -1,18 +1,20 @@ +import { ConsoleWriterModule } from '@infra/console'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { initialisePerformanceObserver } from '@shared/common/measure-utils'; import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; -import { LoggerModule } from '@src/core/logger'; -import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; -import { RabbitMQWrapperModule } from '@infra/rabbitmq'; -import { ConsoleWriterModule } from '@infra/console'; +import { CoreModule } from '@src/core'; +import { Logger, LoggerModule } from '@src/core/logger'; import { ConsoleModule } from 'nestjs-console'; import { FilesStorageClientModule } from '../files-storage-client'; -import { config, TLDRAW_DB_URL } from './config'; +import { config, TldrawConfig, TLDRAW_DB_URL } from './config'; import { TldrawDrawing } from './entities'; -import { TldrawFilesStorageAdapterService } from './service'; -import { TldrawRepo, YMongodb } from './repo'; import { TldrawFilesConsole } from './job'; +import { TldrawRepo, YMongodb } from './repo'; +import { TldrawFilesStorageAdapterService } from './service'; import { TldrawDeleteFilesUc } from './uc'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @@ -23,11 +25,13 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @Module({ imports: [ + CoreModule, ConsoleModule, ConsoleWriterModule, RabbitMQWrapperModule, FilesStorageClientModule, LoggerModule, + CoreModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -40,4 +44,11 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { ], providers: [TldrawRepo, YMongodb, TldrawFilesConsole, TldrawFilesStorageAdapterService, TldrawDeleteFilesUc], }) -export class TldrawConsoleModule {} +export class TldrawConsoleModule { + constructor(private readonly logger: Logger, private readonly configService: ConfigService) { + if (this.configService.get('PERFORMANCE_MEASURE_ENABLED') === true) { + this.logger.setContext('PerformanceObserver'); + initialisePerformanceObserver(this.logger); + } + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 3f096352a4a..a4d7ba2ede6 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -1,18 +1,19 @@ +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { HttpModule } from '@nestjs/axios'; import { Module, NotFoundException } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { initialisePerformanceObserver } from '@shared/common/measure-utils'; import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; -import { LoggerModule } from '@src/core/logger'; -import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; -import { HttpModule } from '@nestjs/axios'; +import { Logger, LoggerModule } from '@src/core/logger'; +import { config, TldrawConfig, TLDRAW_DB_URL } from './config'; +import { TldrawWs } from './controller'; import { TldrawDrawing } from './entities'; import { MetricsService } from './metrics'; +import { TldrawRedisFactory, TldrawRedisService } from './redis'; import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; import { TldrawWsService } from './service'; -import { TldrawWs } from './controller'; -import { config, TLDRAW_DB_URL } from './config'; -import { TldrawRedisFactory, TldrawRedisService } from './redis'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -45,4 +46,11 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { TldrawRedisService, ], }) -export class TldrawWsModule {} +export class TldrawWsModule { + constructor(private readonly logger: Logger, private readonly configService: ConfigService) { + if (this.configService.get('PERFORMANCE_MEASURE_ENABLED') === true) { + this.logger.setContext('PerformanceObserver'); + initialisePerformanceObserver(this.logger); + } + } +} 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 index 7e34d995267..548c737c064 100644 --- 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 @@ -7,8 +7,12 @@ export class ContextExternalToolCountPerContextResponse { @ApiProperty() boardElement: number; + @ApiProperty() + mediaBoard: number; + constructor(props: ContextExternalToolCountPerContextResponse) { this.course = props.course; this.boardElement = props.boardElement; + this.mediaBoard = props.mediaBoard; } } diff --git a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts index 563118a175d..35b04c2d3b4 100644 --- a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts +++ b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts @@ -4,7 +4,6 @@ import { LoggerModule } from '@src/core/logger'; import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; -import { ToolConfigModule } from '../tool-config.module'; import { ContextExternalToolAuthorizableService, ContextExternalToolService, ToolReferenceService } from './service'; import { ContextExternalToolValidationService } from './service/context-external-tool-validation.service'; import { ToolConfigurationStatusService } from './service/tool-configuration-status.service'; @@ -15,7 +14,6 @@ import { ToolConfigurationStatusService } from './service/tool-configuration-sta forwardRef(() => ExternalToolModule), SchoolExternalToolModule, LoggerModule, - ToolConfigModule, UserLicenseModule, ], providers: [ diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.spec.ts index 1dc112369d3..587d9731b6f 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.spec.ts @@ -443,7 +443,7 @@ describe(ToolConfigurationStatusService.name, () => { const externalTool = externalToolFactory.buildWithId(); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id, - status: { isDeactivated: true }, + isDeactivated: true, }); const contextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id) diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts index b5464fbac89..e1387d59dc5 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts @@ -72,7 +72,7 @@ export class ToolConfigurationStatusService { } private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool): boolean { - return !!(externalTool.isDeactivated || (schoolExternalTool.status && schoolExternalTool.status.isDeactivated)); + return externalTool.isDeactivated || schoolExternalTool.isDeactivated; } private async isToolLicensed(externalTool: ExternalTool, userId: EntityId): Promise { diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts index 695e5e80066..20154bf1311 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts @@ -6,11 +6,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Course, LegacyBoard, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, boardFactory, courseFactory, schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; @@ -197,6 +197,7 @@ describe('ToolConfigurationController (API)', () => { externalToolId: externalTool.id, schoolExternalToolId: schoolExternalTool.id, name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: [ { @@ -217,6 +218,7 @@ describe('ToolConfigurationController (API)', () => { { externalToolId: externalToolWithoutContextRestriction.id, name: externalToolWithoutContextRestriction.name, + baseUrl: externalToolWithoutContextRestriction.config.baseUrl, parameters: [], schoolExternalToolId: schoolExternalTool2.id, }, @@ -234,6 +236,7 @@ describe('ToolConfigurationController (API)', () => { { externalToolId: externalToolWithoutContextRestriction.id, name: externalToolWithoutContextRestriction.name, + baseUrl: externalToolWithoutContextRestriction.config.baseUrl, parameters: [], schoolExternalToolId: schoolExternalTool2.id, }, @@ -353,6 +356,7 @@ describe('ToolConfigurationController (API)', () => { { externalToolId: externalTool.id, name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: [ { @@ -480,6 +484,7 @@ describe('ToolConfigurationController (API)', () => { expect(response.body).toEqual({ externalToolId: externalTool.id, name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: [ { @@ -635,6 +640,7 @@ describe('ToolConfigurationController (API)', () => { externalToolId: externalTool.id, schoolExternalToolId: schoolExternalTool.id, name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: [ { diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index 6361294bd08..5e304b9bb60 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -785,6 +785,12 @@ describe('ToolController (API)', () => { contextId: new ObjectId().toHexString(), }); + const mediaBoardTools: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildListWithId(2, { + schoolTool: schoolExternalToolEntitys[1], + contextType: ContextExternalToolType.MEDIA_BOARD, + contextId: new ObjectId().toHexString(), + }); + const board = columnBoardEntityFactory.build(); const externalToolElements = externalToolElementEntityFactory .withParent(board) @@ -799,6 +805,7 @@ describe('ToolController (API)', () => { ...schoolExternalToolEntitys, ...courseTools, ...boardTools, + ...mediaBoardTools, board, ...externalToolElements, ]); @@ -820,6 +827,7 @@ describe('ToolController (API)', () => { contextExternalToolCountPerContext: { course: 1, boardElement: 1, + mediaBoard: 1, }, }); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts index e16495bbacc..be0a5631269 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/context-external-tool-configuration-template.response.ts @@ -12,6 +12,9 @@ export class ContextExternalToolConfigurationTemplateResponse { @ApiProperty() name: string; + @ApiProperty() + baseUrl: string; + @ApiPropertyOptional() logoUrl?: string; @@ -22,6 +25,7 @@ export class ContextExternalToolConfigurationTemplateResponse { this.externalToolId = configuration.externalToolId; this.schoolExternalToolId = configuration.schoolExternalToolId; this.name = configuration.name; + this.baseUrl = configuration.baseUrl; this.logoUrl = configuration.logoUrl; this.parameters = configuration.parameters; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts index 85d4d428e94..50915feaaa7 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/school-external-tool-configuration-template.response.ts @@ -9,6 +9,9 @@ export class SchoolExternalToolConfigurationTemplateResponse { @ApiProperty() name: string; + @ApiProperty() + baseUrl: string; + @ApiPropertyOptional() logoUrl?: string; @@ -18,6 +21,7 @@ export class SchoolExternalToolConfigurationTemplateResponse { constructor(configuration: SchoolExternalToolConfigurationTemplateResponse) { this.externalToolId = configuration.externalToolId; this.name = configuration.name; + this.baseUrl = configuration.baseUrl; this.logoUrl = configuration.logoUrl; this.parameters = configuration.parameters; } diff --git a/apps/server/src/modules/tool/external-tool/external-tool.module.ts b/apps/server/src/modules/tool/external-tool/external-tool.module.ts index c7273f73f0e..20d96485a9a 100644 --- a/apps/server/src/modules/tool/external-tool/external-tool.module.ts +++ b/apps/server/src/modules/tool/external-tool/external-tool.module.ts @@ -6,7 +6,6 @@ import { ExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { CommonToolModule } from '../common'; import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; -import { ToolConfigModule } from '../tool-config.module'; import { ExternalToolMetadataMapper } from './mapper'; import { DatasheetPdfService, @@ -20,7 +19,7 @@ import { } from './service'; @Module({ - imports: [CommonToolModule, ToolConfigModule, LoggerModule, OauthProviderServiceModule, EncryptionModule, HttpModule], + imports: [CommonToolModule, LoggerModule, OauthProviderServiceModule, EncryptionModule, HttpModule], providers: [ ExternalToolService, ExternalToolServiceMapper, diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts index 26bc472710c..3cc59845750 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts @@ -37,7 +37,7 @@ describe(ExternalToolDatasheetMapper.name, () => { restrictToContexts: [ToolContextType.COURSE, ToolContextType.BOARD_ELEMENT], }); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - status: { isDeactivated: true }, + isDeactivated: true, }); const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory .withOptionalProperties() @@ -68,7 +68,7 @@ describe(ExternalToolDatasheetMapper.name, () => { const school: School = schoolFactory.build(); const externalTool = externalToolFactory.build(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - status: { isDeactivated: true }, + isDeactivated: true, }); const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ instance: 'dBildungscloud', diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts index 4a336ff604e..d35e86b8d82 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts @@ -61,7 +61,7 @@ export class ExternalToolDatasheetMapper { return 'Das Tool ist instanzweit deaktiviert'; } - if (schoolExternalTool?.status?.isDeactivated) { + if (schoolExternalTool?.isDeactivated) { return 'Das Tool ist deaktiviert'; } diff --git a/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts index 4821569e872..37d67c810bc 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts @@ -15,8 +15,9 @@ export class ToolConfigurationMapper { externalTool: ExternalTool ): SchoolExternalToolConfigurationTemplateResponse { const mapped = new SchoolExternalToolConfigurationTemplateResponse({ - externalToolId: externalTool.id ?? '', + externalToolId: externalTool.id, name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: externalTool.parameters ? ExternalToolResponseMapper.mapCustomParameterToResponse(externalTool.parameters) @@ -48,6 +49,7 @@ export class ToolConfigurationMapper { externalToolId: externalTool.id ?? '', schoolExternalToolId: schoolExternalTool.id ?? '', name: externalTool.name, + baseUrl: externalTool.config.baseUrl, logoUrl: externalTool.logoUrl, parameters: externalTool.parameters ? ExternalToolResponseMapper.mapCustomParameterToResponse(externalTool.parameters) diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts index 54b5149f57a..d6240b1c242 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts @@ -11,7 +11,6 @@ import { schoolExternalToolConfigurationStatusFactory, schoolExternalToolFactory, } from '../../school-external-tool/testing'; -import { ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { customParameterFactory, externalToolFactory } from '../testing'; import { ContextExternalToolTemplateInfo } from '../uc'; @@ -28,12 +27,6 @@ describe('ExternalToolConfigurationService', () => { module = await Test.createTestingModule({ providers: [ ExternalToolConfigurationService, - { - provide: ToolFeatures, - useValue: { - contextConfigurationEnabled: false, - }, - }, { provide: CommonToolService, useValue: createMock(), @@ -129,12 +122,12 @@ describe('ExternalToolConfigurationService', () => { availableSchoolExternalTools.forEach((tool): void => { if (tool.id === 'deactivatedToolId') { tool.status = schoolExternalToolConfigurationStatusFactory.build({ - isDeactivated: true, + isGloballyDeactivated: true, isOutdatedOnScopeSchool: false, }); } tool.status = schoolExternalToolConfigurationStatusFactory.build({ - isDeactivated: false, + isGloballyDeactivated: false, isOutdatedOnScopeSchool: false, }); }); @@ -175,9 +168,7 @@ describe('ExternalToolConfigurationService', () => { ); expect( - result.every( - (toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.schoolExternalTool.status?.isDeactivated - ) + result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.schoolExternalTool.isDeactivated) ).toBe(true); }); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts index e46ad499f8c..f41dcace98e 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts @@ -38,7 +38,7 @@ export class ExternalToolConfigurationService { const availableTools: ContextExternalToolTemplateInfo[] = unusedTools .filter((toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden) .filter((toolRef) => !toolRef.externalTool.isDeactivated) - .filter((toolRef) => !toolRef.schoolExternalTool.status?.isDeactivated); + .filter((toolRef) => !toolRef.schoolExternalTool.isDeactivated); return availableTools; } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.spec.ts similarity index 95% rename from apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts rename to apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.spec.ts index 40d7185ac98..c374e1362d0 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.spec.ts @@ -1,11 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { of, throwError } from 'rxjs'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ToolConfig } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ExternalToolLogo } from '../domain/external-tool-logo'; import { @@ -19,13 +20,13 @@ import { externalToolFactory } from '../testing'; import { ExternalToolLogoService } from './external-tool-logo.service'; import { ExternalToolService } from './external-tool.service'; -describe('ExternalToolLogoService', () => { +describe(ExternalToolLogoService.name, () => { let module: TestingModule; let service: ExternalToolLogoService; let httpService: DeepMocked; let logger: DeepMocked; - let toolFeatures: IToolFeatures; + let configService: DeepMocked>; let externalToolService: DeepMocked; beforeAll(async () => { @@ -41,10 +42,8 @@ describe('ExternalToolLogoService', () => { useValue: createMock(), }, { - provide: ToolFeatures, - useValue: { - maxExternalToolLogoSizeInBytes: 30000, - }, + provide: ConfigService, + useValue: createMock>(), }, { provide: ExternalToolService, @@ -56,7 +55,7 @@ describe('ExternalToolLogoService', () => { service = module.get(ExternalToolLogoService); httpService = module.get(HttpService); logger = module.get(Logger); - toolFeatures = module.get(ToolFeatures); + configService = module.get(ConfigService); externalToolService = module.get(ExternalToolService); }); @@ -91,7 +90,8 @@ describe('ExternalToolLogoService', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); - const baseUrl = toolFeatures.backEndUrl; + const baseUrl = 'https://backend.com'; + configService.get.mockReturnValue(baseUrl); const { id } = externalTool; const expected = `${baseUrl}/v3/tools/external-tools/${id}/logo`; @@ -116,7 +116,7 @@ describe('ExternalToolLogoService', () => { describe('when size is exceeded', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); - toolFeatures.maxExternalToolLogoSizeInBytes = 1; + configService.get.mockReturnValue(1); return { externalTool }; }; @@ -133,7 +133,7 @@ describe('ExternalToolLogoService', () => { describe('when size is not exceeded', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); - toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + configService.get.mockReturnValue(30000); return { externalTool }; }; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts index 2a274f1a17d..01c4689ad53 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts @@ -1,10 +1,11 @@ import { HttpService } from '@nestjs/axios'; -import { HttpException, Inject } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; import { lastValueFrom } from 'rxjs'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ToolConfig } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ExternalToolLogo } from '../domain/external-tool-logo'; import { @@ -23,17 +24,18 @@ const contentTypeDetector: Record = { '47494638': 'image/gif', }; +@Injectable() export class ExternalToolLogoService { constructor( - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly configService: ConfigService, private readonly logger: Logger, private readonly httpService: HttpService, private readonly externalToolService: ExternalToolService ) {} - buildLogoUrl(externalTool: ExternalTool): string | undefined { + public buildLogoUrl(externalTool: ExternalTool): string | undefined { const { logo, id } = externalTool; - const backendUrl = this.toolFeatures.backEndUrl; + const backendUrl = this.configService.get('CTL_TOOLS_BACKEND_URL'); if (logo && id) { return `${backendUrl}/v3/tools/external-tools/${id}/logo`; @@ -42,22 +44,22 @@ export class ExternalToolLogoService { return undefined; } - validateLogoSize(externalTool: Partial): void { + public validateLogoSize(externalTool: Partial): void { if (!externalTool.logo) { return; } const buffer: Buffer = Buffer.from(externalTool.logo, 'base64'); - if (buffer.length > this.toolFeatures.maxExternalToolLogoSizeInBytes) { + if (buffer.length > this.configService.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES')) { throw new ExternalToolLogoSizeExceededLoggableException( externalTool.id, - this.toolFeatures.maxExternalToolLogoSizeInBytes + this.configService.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') ); } } - async fetchLogo(externalTool: Partial): Promise { + public async fetchLogo(externalTool: Partial): Promise { if (externalTool.logoUrl) { const base64Logo: string = await this.fetchBase64Logo(externalTool.logoUrl); @@ -93,7 +95,7 @@ export class ExternalToolLogoService { } } - async getExternalToolBinaryLogo(toolId: EntityId): Promise { + public async getExternalToolBinaryLogo(toolId: EntityId): Promise { const tool: ExternalTool = await this.externalToolService.findById(toolId); if (!tool.logo) { diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts index 7216a57414e..ae618ae084a 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts @@ -1,7 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ToolConfig } from '../../tool-config'; import { ExternalTool } from '../domain'; import { externalToolFactory } from '../testing'; import { ExternalToolLogoService } from './external-tool-logo.service'; @@ -9,13 +10,13 @@ import { ExternalToolParameterValidationService } from './external-tool-paramete import { ExternalToolValidationService } from './external-tool-validation.service'; import { ExternalToolService } from './external-tool.service'; -describe('ExternalToolValidationService', () => { +describe(ExternalToolValidationService.name, () => { let module: TestingModule; let service: ExternalToolValidationService; let externalToolService: DeepMocked; let commonToolValidationService: DeepMocked; - let toolFeatures: IToolFeatures; + let configService: DeepMocked>; let logoService: DeepMocked; beforeAll(async () => { @@ -31,10 +32,8 @@ describe('ExternalToolValidationService', () => { useValue: createMock(), }, { - provide: ToolFeatures, - useValue: { - maxExternalToolLogoSizeInBytes: 30000, - }, + provide: ConfigService, + useValue: createMock>(), }, { provide: ExternalToolLogoService, @@ -46,7 +45,7 @@ describe('ExternalToolValidationService', () => { service = module.get(ExternalToolValidationService); externalToolService = module.get(ExternalToolService); commonToolValidationService = module.get(ExternalToolParameterValidationService); - toolFeatures = module.get(ToolFeatures); + configService = module.get(ConfigService); logoService = module.get(ExternalToolLogoService); }); @@ -189,7 +188,7 @@ describe('ExternalToolValidationService', () => { describe('when external tool has a given base64 logo', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); - toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + configService.get.mockReturnValue(30000); return { externalTool }; }; @@ -362,7 +361,7 @@ describe('ExternalToolValidationService', () => { describe('when external tool has a given base64 logo', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().build(); - toolFeatures.maxExternalToolLogoSizeInBytes = 30000; + configService.get.mockReturnValue(30000); return { externalTool }; }; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts index b43a67a37b2..abc6ec6ab07 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts @@ -10,6 +10,7 @@ import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } fro import { LegacyLogger } from '@src/core/logger'; import { ExternalToolSearchQuery } from '../../common/interface'; import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; import { externalToolFactory, lti11ToolConfigFactory, oauth2ToolConfigFactory } from '../testing'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; @@ -375,16 +376,13 @@ describe(ExternalToolService.name, () => { const setup = () => { createTools(); - const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ - id: 'schoolTool1', - toolId: 'tool1', - schoolId: 'school1', - parameters: [], - }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); schoolToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalTool]); - return { schoolExternalTool }; + return { + schoolExternalTool, + }; }; describe('when tool id is set', () => { diff --git a/apps/server/src/modules/tool/index.ts b/apps/server/src/modules/tool/index.ts index f6ba7329778..a8006029057 100644 --- a/apps/server/src/modules/tool/index.ts +++ b/apps/server/src/modules/tool/index.ts @@ -2,5 +2,4 @@ export * from './common/interface'; export * from './context-external-tool/service/context-external-tool-authorizable.service'; export * from './external-tool'; export * from './tool.module'; -export { default as ToolConfiguration, IToolFeatures } from './tool-config'; export { ExternalToolAuthorizableService } from './external-tool/service/external-tool-authorizable.service'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 1b62a7128fc..14337c32f60 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -130,8 +130,9 @@ describe('ToolSchoolController (API)', () => { const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(response.body).toEqual({ + expect(response.body).toEqual({ id: expect.any(String), + isDeactivated: postParams.isDeactivated, name: externalToolEntity.name, schoolId: postParams.schoolId, toolId: postParams.toolId, @@ -231,6 +232,7 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ parameters: [], + isDeactivated: true, }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ @@ -284,16 +286,18 @@ describe('ToolSchoolController (API)', () => { expect(response.statusCode).toEqual(HttpStatus.OK); expect(response.body).toEqual( - expect.objectContaining({ + expect.objectContaining({ data: [ { id: schoolExternalToolEntity.id, name: externalToolEntity.name, schoolId: school.id, toolId: externalToolEntity.id, - status: schoolExternalToolConfigurationStatusFactory.build({ + isDeactivated: false, + status: { isOutdatedOnScopeSchool: true, - }), + isGloballyDeactivated: true, + }, parameters: [ { name: schoolExternalToolEntity.schoolParameters[0].name, @@ -322,6 +326,7 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ parameters: [], + isDeactivated: true, }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ @@ -331,12 +336,14 @@ describe('ToolSchoolController (API)', () => { const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ id: schoolExternalToolEntity.id, - name: '', + name: externalToolEntity.name, schoolId: school.id, toolId: externalToolEntity.id, - status: schoolExternalToolConfigurationStatusFactory.build({ - isOutdatedOnScopeSchool: false, - }), + isDeactivated: false, + status: { + isOutdatedOnScopeSchool: true, + isGloballyDeactivated: true, + }, parameters: [ { name: schoolExternalToolEntity.schoolParameters[0].name, @@ -459,9 +466,11 @@ describe('ToolSchoolController (API)', () => { name: externalToolEntity.name, schoolId: postParamsUpdate.schoolId, toolId: postParamsUpdate.toolId, - status: schoolExternalToolConfigurationStatusFactory.build({ + isDeactivated: false, + status: { isOutdatedOnScopeSchool: false, - }), + isGloballyDeactivated: false, + }, parameters: [ { name: updatedParamEntry.name, @@ -547,6 +556,13 @@ describe('ToolSchoolController (API)', () => { } ); + const mediaBoardExternalToolEntitys: ContextExternalToolEntity[] = + contextExternalToolEntityFactory.buildListWithId(2, { + schoolTool: schoolExternalToolEntity, + contextType: ContextExternalToolType.MEDIA_BOARD, + contextId: new ObjectId().toHexString(), + }); + const board = columnBoardEntityFactory.build(); const externalToolElements = externalToolElementEntityFactory.withParent(board).buildList(2, { contextExternalToolId: boardExternalToolEntitys[0].id, @@ -562,6 +578,7 @@ describe('ToolSchoolController (API)', () => { schoolExternalToolEntity, ...courseExternalToolEntitys, ...boardExternalToolEntitys, + ...mediaBoardExternalToolEntitys, board, ...externalToolElements, ]); @@ -582,6 +599,7 @@ describe('ToolSchoolController (API)', () => { contextExternalToolCountPerContext: { course: 1, boardElement: 1, + mediaBoard: 1, }, }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts index 3631b6989b4..b3e0ba2d6ed 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts @@ -6,4 +6,4 @@ export * from './school-external-tool-post.params'; export * from './school-external-tool-search.params'; export * from './school-external-tool-search-list.response'; export * from './school-external-tool-metadata.response'; -export * from '../domain/school-external-tool-configuration-status'; +export { SchoolExternalToolConfigurationStatusResponse } from './school-external-tool-configuration.response'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts index 36d500ba88e..2807b060c22 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts @@ -10,12 +10,12 @@ export class SchoolExternalToolConfigurationStatusResponse { @ApiProperty({ type: Boolean, - description: 'Is the tool deactivated, because of school administrator?', + description: 'Is the tool deactivated, because of instance administrator?', }) - isDeactivated: boolean; + isGloballyDeactivated: boolean; constructor(props: SchoolExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; - this.isDeactivated = props.isDeactivated; + this.isGloballyDeactivated = props.isGloballyDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts index 2ca015c5538..76e4affd524 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { CustomParameterEntryResponse } from './custom-parameter-entry.response'; import { SchoolExternalToolConfigurationStatusResponse } from './school-external-tool-configuration.response'; @@ -15,22 +15,22 @@ export class SchoolExternalToolResponse { @ApiProperty() schoolId: string; + @ApiProperty() + isDeactivated: boolean; + @ApiProperty({ type: [CustomParameterEntryResponse] }) parameters: CustomParameterEntryResponse[]; @ApiProperty({ type: SchoolExternalToolConfigurationStatusResponse }) status: SchoolExternalToolConfigurationStatusResponse; - @ApiPropertyOptional() - logoUrl?: string; - constructor(response: SchoolExternalToolResponse) { this.id = response.id; this.name = response.name; this.toolId = response.toolId; this.schoolId = response.schoolId; + this.isDeactivated = response.isDeactivated; this.parameters = response.parameters; this.status = response.status; - this.logoUrl = response.logoUrl; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts index 44b1754ea9a..abfff864d11 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts @@ -15,14 +15,13 @@ import { import { ValidationError } from '@shared/common'; import { LegacyLogger } from '@src/core/logger'; import { ExternalToolSearchListResponse } from '../../external-tool/controller/dto'; -import { SchoolExternalTool, SchoolExternalToolMetadata } from '../domain'; +import { SchoolExternalTool, SchoolExternalToolMetadata, SchoolExternalToolProps } from '../domain'; import { SchoolExternalToolMetadataMapper, SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper, } from '../mapper'; import { SchoolExternalToolUc } from '../uc'; -import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { SchoolExternalToolIdParams, SchoolExternalToolMetadataResponse, @@ -36,12 +35,7 @@ import { @Authenticate('jwt') @Controller('tools/school-external-tools') export class ToolSchoolController { - constructor( - private readonly schoolExternalToolUc: SchoolExternalToolUc, - private readonly responseMapper: SchoolExternalToolResponseMapper, - private readonly requestMapper: SchoolExternalToolRequestMapper, - private readonly logger: LegacyLogger - ) {} + constructor(private readonly schoolExternalToolUc: SchoolExternalToolUc, private readonly logger: LegacyLogger) {} @Get() @ApiFoundResponse({ description: 'SchoolExternalTools has been found.', type: ExternalToolSearchListResponse }) @@ -55,7 +49,8 @@ export class ToolSchoolController { const found: SchoolExternalTool[] = await this.schoolExternalToolUc.findSchoolExternalTools(currentUser.userId, { schoolId: schoolExternalToolParams.schoolId, }); - const response: SchoolExternalToolSearchListResponse = this.responseMapper.mapToSearchListResponse(found); + const response: SchoolExternalToolSearchListResponse = + SchoolExternalToolResponseMapper.mapToSearchListResponse(found); return response; } @@ -71,7 +66,8 @@ export class ToolSchoolController { currentUser.userId, params.schoolExternalToolId ); - const mapped: SchoolExternalToolResponse = this.responseMapper.mapToSchoolExternalToolResponse(schoolExternalTool); + const mapped: SchoolExternalToolResponse = + SchoolExternalToolResponseMapper.mapToSchoolExternalToolResponse(schoolExternalTool); return mapped; } @@ -86,14 +82,16 @@ export class ToolSchoolController { @Param() params: SchoolExternalToolIdParams, @Body() body: SchoolExternalToolPostParams ): Promise { - const schoolExternalToolDto: SchoolExternalToolDto = this.requestMapper.mapSchoolExternalToolRequest(body); + const schoolExternalToolDto: SchoolExternalToolProps = + SchoolExternalToolRequestMapper.mapSchoolExternalToolRequest(body); const updated: SchoolExternalTool = await this.schoolExternalToolUc.updateSchoolExternalTool( currentUser.userId, params.schoolExternalToolId, schoolExternalToolDto ); - const mapped: SchoolExternalToolResponse = this.responseMapper.mapToSchoolExternalToolResponse(updated); + const mapped: SchoolExternalToolResponse = + SchoolExternalToolResponseMapper.mapToSchoolExternalToolResponse(updated); this.logger.debug(`SchoolExternalTool with id ${mapped.id} was updated by user with id ${currentUser.userId}`); return mapped; } @@ -127,7 +125,8 @@ export class ToolSchoolController { @CurrentUser() currentUser: ICurrentUser, @Body() body: SchoolExternalToolPostParams ): Promise { - const schoolExternalToolDto: SchoolExternalToolDto = this.requestMapper.mapSchoolExternalToolRequest(body); + const schoolExternalToolDto: SchoolExternalToolProps = + SchoolExternalToolRequestMapper.mapSchoolExternalToolRequest(body); const createdSchoolExternalToolDO: SchoolExternalTool = await this.schoolExternalToolUc.createSchoolExternalTool( currentUser.userId, @@ -135,7 +134,7 @@ export class ToolSchoolController { ); const response: SchoolExternalToolResponse = - this.responseMapper.mapToSchoolExternalToolResponse(createdSchoolExternalToolDO); + SchoolExternalToolResponseMapper.mapToSchoolExternalToolResponse(createdSchoolExternalToolDO); this.logger.debug(`SchoolExternalTool with id ${response.id} was created by user with id ${currentUser.userId}`); diff --git a/apps/server/src/modules/tool/school-external-tool/domain/index.ts b/apps/server/src/modules/tool/school-external-tool/domain/index.ts index c0716afd84b..2a85b86634b 100644 --- a/apps/server/src/modules/tool/school-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/index.ts @@ -1,3 +1,4 @@ export * from './school-external-tool.do'; export * from './school-external-tool.ref'; export * from './school-external-tool-metadata'; +export { SchoolExternalToolConfigurationStatus } from './school-external-tool-configuration-status'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-configuration-status.ts similarity index 70% rename from apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts rename to apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-configuration-status.ts index b8dcfcd13d3..104dbed6197 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-configuration-status.ts @@ -1,10 +1,10 @@ export class SchoolExternalToolConfigurationStatus { isOutdatedOnScopeSchool: boolean; - isDeactivated: boolean; + isGloballyDeactivated: boolean; constructor(props: SchoolExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; - this.isDeactivated = props.isDeactivated; + this.isGloballyDeactivated = props.isGloballyDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts index bad765ea17f..4d8ed9b8a9f 100644 --- a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool.do.ts @@ -1,6 +1,6 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { CustomParameterEntry } from '../../common/domain'; -import { SchoolExternalToolConfigurationStatus } from '../controller/dto'; +import { SchoolExternalToolConfigurationStatus } from './school-external-tool-configuration-status'; export interface SchoolExternalToolProps extends AuthorizableObject { id: string; @@ -13,6 +13,8 @@ export interface SchoolExternalToolProps extends AuthorizableObject { parameters: CustomParameterEntry[]; + isDeactivated: boolean; + status?: SchoolExternalToolConfigurationStatus; } @@ -37,11 +39,15 @@ export class SchoolExternalTool extends DomainObject { return this.props.parameters; } - get status(): SchoolExternalToolConfigurationStatus | undefined { - return this.props.status; + get isDeactivated(): boolean { + return this.props.isDeactivated; + } + + get status(): SchoolExternalToolConfigurationStatus { + return this.props.status ?? { isOutdatedOnScopeSchool: false, isGloballyDeactivated: false }; } - set status(value: SchoolExternalToolConfigurationStatus | undefined) { + set status(value: SchoolExternalToolConfigurationStatus) { this.props.status = value; } } diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts deleted file mode 100644 index 87a3985ac31..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { setupEntities } from '@shared/testing'; -import { schoolExternalToolConfigurationStatusEntityFactory } from '../testing/school-external-tool-configuration-status-entity.factory'; -import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; - -describe('SchoolExternalToolConfigurationStatusEntity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - it('should throw an error by empty constructor', () => { - // @ts-expect-error: Test case - const test = () => new SchoolExternalToolConfigurationStatusEntity(); - expect(test).toThrow(); - }); - - it('should create a school external tool configuration status by passing required properties', () => { - const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = - schoolExternalToolConfigurationStatusEntityFactory.build(); - expect( - schoolExternalToolConfigurationStatusEntity instanceof SchoolExternalToolConfigurationStatusEntity - ).toEqual(false); - }); - - it('should set school external tool status', () => { - const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = - new SchoolExternalToolConfigurationStatusEntity({ - isDeactivated: true, - isOutdatedOnScopeSchool: false, - }); - - expect(schoolExternalToolConfigurationStatusEntity).toEqual({ - isDeactivated: true, - isOutdatedOnScopeSchool: false, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts deleted file mode 100644 index ea071f996e1..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Embeddable, Property } from '@mikro-orm/core'; - -@Embeddable() -export class SchoolExternalToolConfigurationStatusEntity { - @Property() - isOutdatedOnScopeSchool: boolean; - - @Property() - isDeactivated: boolean; - - constructor(props: SchoolExternalToolConfigurationStatusEntity) { - this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; - this.isDeactivated = props.isDeactivated; - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts deleted file mode 100644 index d1f03b47d80..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { schoolEntityFactory, setupEntities } from '@shared/testing'; -import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; -import { CustomParameterEntity, ExternalToolConfigEntity, ExternalToolEntity } from '../../external-tool/entity'; -import { - basicToolConfigFactory, - customParameterEntityFactory, - externalToolEntityFactory, -} from '../../external-tool/testing'; -import { schoolExternalToolConfigurationStatusEntityFactory, schoolExternalToolEntityFactory } from '../testing'; -import { SchoolExternalToolEntity } from './school-external-tool.entity'; - -describe('SchoolExternalToolEntity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - it('should throw an error by empty constructor', () => { - // @ts-expect-error: Test case - const test = () => new SchoolExternalToolEntity(); - expect(test).toThrow(); - }); - - it('should create an external school Tool by passing required properties', () => { - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); - expect(schoolExternalToolEntity instanceof SchoolExternalToolEntity).toEqual(true); - }); - - it('should set schoolParameters to empty when is undefined', () => { - const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - }); - const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ - name: 'parameterName', - displayName: 'User Friendly Name', - default: 'mock', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - isProtected: false, - }); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ - name: 'toolName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - config: externalToolConfigEntity, - parameters: [customParameter], - isHidden: true, - openNewTab: true, - isDeactivated: false, - }); - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ - tool: externalToolEntity, - school: schoolEntityFactory.buildWithId(), - schoolParameters: [], - status: schoolExternalToolConfigurationStatusEntityFactory.build(), - }); - - expect(schoolExternalToolEntity.schoolParameters).toEqual([]); - }); - - it('should set school external tool configuration status', () => { - const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - }); - const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ - name: 'parameterName', - displayName: 'User Friendly Name', - default: 'mock', - location: CustomParameterLocation.PATH, - scope: CustomParameterScope.SCHOOL, - type: CustomParameterType.STRING, - regex: 'mockRegex', - regexComment: 'mockComment', - isOptional: false, - isProtected: false, - }); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ - name: 'toolName', - url: 'mockUrl', - logoUrl: 'mockLogoUrl', - config: externalToolConfigEntity, - parameters: [customParameter], - isHidden: true, - openNewTab: true, - isDeactivated: false, - }); - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ - tool: externalToolEntity, - school: schoolEntityFactory.buildWithId(), - schoolParameters: [], - status: schoolExternalToolConfigurationStatusEntityFactory.build(), - }); - - expect(schoolExternalToolEntity.status).toEqual({ isDeactivated: false, isOutdatedOnScopeSchool: false }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts index cd798abfccd..a43539d371b 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -1,10 +1,9 @@ -import { Embedded, Entity, ManyToOne } from '@mikro-orm/core'; +import { Embedded, Entity, ManyToOne, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { EntityId } from '@shared/domain/types'; import { CustomParameterEntryEntity } from '../../common/entity'; import { ExternalToolEntity } from '../../external-tool/entity'; -import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; export interface SchoolExternalToolEntityProps { id?: EntityId; @@ -15,7 +14,7 @@ export interface SchoolExternalToolEntityProps { schoolParameters?: CustomParameterEntryEntity[]; - status?: SchoolExternalToolConfigurationStatusEntity; + isDeactivated: boolean; } @Entity({ tableName: 'school-external-tools' }) @@ -29,8 +28,8 @@ export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { @Embedded(() => CustomParameterEntryEntity, { array: true }) schoolParameters: CustomParameterEntryEntity[]; - @Embedded(() => SchoolExternalToolConfigurationStatusEntity, { object: true, nullable: true }) - status?: SchoolExternalToolConfigurationStatusEntity; + @Property() + isDeactivated: boolean; constructor(props: SchoolExternalToolEntityProps) { super(); @@ -40,6 +39,6 @@ export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { this.tool = props.tool; this.school = props.school; this.schoolParameters = props.schoolParameters ?? []; - this.status = props.status; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/index.ts b/apps/server/src/modules/tool/school-external-tool/mapper/index.ts index 4b663b7b50b..48cd0b13a20 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/index.ts @@ -1,4 +1,3 @@ export * from './school-external-tool-request.mapper'; export * from './school-external-tool-response.mapper'; export * from './school-external-tool-metadata.mapper'; -export * from './school-external-tool-status-response.mapper'; diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts index 9fe3f7f1d33..abab0048a6e 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts @@ -1,11 +1,8 @@ import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; -import { schoolExternalToolConfigurationStatusFactory } from '../testing'; -import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolProps } from '../domain'; import { SchoolExternalToolRequestMapper } from './school-external-tool-request.mapper'; describe('SchoolExternalToolRequestMapper', () => { - const mapper: SchoolExternalToolRequestMapper = new SchoolExternalToolRequestMapper(); - describe('mapSchoolExternalToolRequest', () => { describe('when SchoolExternalToolPostParams is given', () => { const setup = () => { @@ -29,14 +26,15 @@ describe('SchoolExternalToolRequestMapper', () => { it('should return an schoolExternalTool', () => { const { param, params } = setup(); - const schoolExternalToolDto: SchoolExternalToolDto = mapper.mapSchoolExternalToolRequest(params); + const schoolExternalToolDto: SchoolExternalToolProps = + SchoolExternalToolRequestMapper.mapSchoolExternalToolRequest(params); - expect(schoolExternalToolDto).toEqual({ + expect(schoolExternalToolDto).toEqual({ id: expect.any(String), toolId: params.toolId, parameters: [{ name: param.name, value: param.value }], schoolId: params.schoolId, - status: schoolExternalToolConfigurationStatusFactory.build({ isDeactivated: true }), + isDeactivated: true, }); }); }); @@ -58,14 +56,15 @@ describe('SchoolExternalToolRequestMapper', () => { it('should return an schoolExternalTool without parameter', () => { const { params } = setup(); - const schoolExternalToolDto: SchoolExternalToolDto = mapper.mapSchoolExternalToolRequest(params); + const schoolExternalToolDto: SchoolExternalToolProps = + SchoolExternalToolRequestMapper.mapSchoolExternalToolRequest(params); - expect(schoolExternalToolDto).toEqual({ + expect(schoolExternalToolDto).toEqual({ id: expect.any(String), toolId: params.toolId, parameters: [], schoolId: params.schoolId, - status: schoolExternalToolConfigurationStatusFactory.build(), + isDeactivated: false, }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts index 6e05bc21ed2..b0061ac44ae 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts @@ -1,29 +1,20 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; import { CustomParameterEntry } from '../../common/domain'; -import { - CustomParameterEntryParam, - SchoolExternalToolConfigurationStatus, - SchoolExternalToolPostParams, -} from '../controller/dto'; -import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; +import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; +import { SchoolExternalToolProps } from '../domain'; -@Injectable() export class SchoolExternalToolRequestMapper { - mapSchoolExternalToolRequest(request: SchoolExternalToolPostParams): SchoolExternalToolDto { + public static mapSchoolExternalToolRequest(request: SchoolExternalToolPostParams): SchoolExternalToolProps { return { id: new ObjectId().toHexString(), toolId: request.toolId, schoolId: request.schoolId, - parameters: this.mapRequestToCustomParameterEntryDO(request.parameters ?? []), - status: new SchoolExternalToolConfigurationStatus({ - isOutdatedOnScopeSchool: false, - isDeactivated: request.isDeactivated, - }), + parameters: SchoolExternalToolRequestMapper.mapRequestToCustomParameterEntryDO(request.parameters ?? []), + isDeactivated: request.isDeactivated, }; } - private mapRequestToCustomParameterEntryDO( + private static mapRequestToCustomParameterEntryDO( customParameterParams: CustomParameterEntryParam[] ): CustomParameterEntry[] { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts deleted file mode 100644 index a3dae02d9ba..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { SchoolExternalToolResponse, SchoolExternalToolSearchListResponse } from '../controller/dto'; -import { SchoolExternalTool } from '../domain'; -import { - schoolExternalToolConfigurationStatusFactory, - schoolExternalToolConfigurationStatusResponseFactory, - schoolExternalToolFactory, -} from '../testing'; -import { SchoolExternalToolResponseMapper } from './school-external-tool-response.mapper'; - -describe('SchoolExternalToolResponseMapper', () => { - let mapper: SchoolExternalToolResponseMapper; - - beforeAll(() => { - mapper = new SchoolExternalToolResponseMapper(); - }); - - describe('mapToSearchListResponse', () => { - it('should return a schoolExternalToolResponse', () => { - const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse([]); - - expect(response).toBeInstanceOf(SchoolExternalToolSearchListResponse); - }); - - describe('when parameter are given', () => { - const setup = () => { - const do1: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const do2: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - do2.status = undefined; - - const dos: SchoolExternalTool[] = [do1, do2]; - - return { - dos, - do1, - do2, - }; - }; - - it('should map domain objects correctly', () => { - const { dos, do1, do2 } = setup(); - - const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse(dos); - - expect(response.data).toEqual( - expect.objectContaining([ - { - id: do1.id, - name: do1.name as string, - schoolId: do1.schoolId, - toolId: do1.toolId, - parameters: [ - { - name: do1.parameters[0].name, - value: do1.parameters[0].value, - }, - ], - status: schoolExternalToolConfigurationStatusResponseFactory.build({ - isOutdatedOnScopeSchool: false, - isDeactivated: false, - }), - }, - { - id: do2.id, - name: do2.name as string, - schoolId: do2.schoolId, - toolId: do2.toolId, - parameters: [ - { - name: do2.parameters[0].name, - value: do2.parameters[0].value, - }, - ], - status: schoolExternalToolConfigurationStatusFactory.build({ - isOutdatedOnScopeSchool: false, - isDeactivated: false, - }), - }, - ]) - ); - }); - }); - - describe('when optional parameter are missing', () => { - const setup = () => { - const do1: SchoolExternalTool = schoolExternalToolFactory.build({ id: undefined }); - do1.name = undefined; - do1.status = undefined; - - const do2: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - - const dos: SchoolExternalTool[] = [do1, do2]; - - return { - dos, - }; - }; - - it('should set defaults', () => { - const { dos } = setup(); - - const response: SchoolExternalToolSearchListResponse = mapper.mapToSearchListResponse(dos); - - expect(response.data[0]).toEqual( - expect.objectContaining({ - id: '', - name: '', - status: schoolExternalToolConfigurationStatusResponseFactory.build({ - isOutdatedOnScopeSchool: false, - isDeactivated: false, - }), - }) - ); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts index 71de9168de5..50e75ecd37d 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts @@ -1,36 +1,39 @@ -import { Injectable } from '@nestjs/common'; import { CustomParameterEntry } from '../../common/domain'; import { CustomParameterEntryResponse, + SchoolExternalToolConfigurationStatusResponse, SchoolExternalToolResponse, SchoolExternalToolSearchListResponse, } from '../controller/dto'; import { SchoolExternalTool } from '../domain'; -import { SchoolToolConfigurationStatusResponseMapper } from './school-external-tool-status-response.mapper'; -@Injectable() export class SchoolExternalToolResponseMapper { - mapToSearchListResponse(externalTools: SchoolExternalTool[]): SchoolExternalToolSearchListResponse { + static mapToSearchListResponse(externalTools: SchoolExternalTool[]): SchoolExternalToolSearchListResponse { const responses: SchoolExternalToolResponse[] = externalTools.map((toolDO: SchoolExternalTool) => this.mapToSchoolExternalToolResponse(toolDO) ); + return new SchoolExternalToolSearchListResponse(responses); } - mapToSchoolExternalToolResponse(schoolExternalTool: SchoolExternalTool): SchoolExternalToolResponse { - return { - id: schoolExternalTool.id ?? '', + static mapToSchoolExternalToolResponse(schoolExternalTool: SchoolExternalTool): SchoolExternalToolResponse { + const response: SchoolExternalToolResponse = new SchoolExternalToolResponse({ + id: schoolExternalTool.id, name: schoolExternalTool.name ?? '', toolId: schoolExternalTool.toolId, schoolId: schoolExternalTool.schoolId, - parameters: this.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), - status: SchoolToolConfigurationStatusResponseMapper.mapToResponse( - schoolExternalTool.status ?? { isOutdatedOnScopeSchool: false, isDeactivated: false } - ), - }; + isDeactivated: schoolExternalTool.isDeactivated, + parameters: SchoolExternalToolResponseMapper.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), + status: new SchoolExternalToolConfigurationStatusResponse({ + isOutdatedOnScopeSchool: schoolExternalTool.status.isOutdatedOnScopeSchool, + isGloballyDeactivated: schoolExternalTool.status.isGloballyDeactivated, + }), + }); + + return response; } - private mapToCustomParameterEntryResponse(entries: CustomParameterEntry[]): CustomParameterEntryResponse[] { + private static mapToCustomParameterEntryResponse(entries: CustomParameterEntry[]): CustomParameterEntryResponse[] { return entries.map( (entry: CustomParameterEntry): CustomParameterEntry => new CustomParameterEntryResponse({ diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts deleted file mode 100644 index 001efe071c0..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SchoolExternalToolConfigurationStatus } from '../controller/dto'; -import { SchoolExternalToolConfigurationStatusResponse } from '../controller/dto/school-external-tool-configuration.response'; - -export class SchoolToolConfigurationStatusResponseMapper { - static mapToResponse(status: SchoolExternalToolConfigurationStatus): SchoolExternalToolConfigurationStatusResponse { - const configurationStatus: SchoolExternalToolConfigurationStatusResponse = - new SchoolExternalToolConfigurationStatusResponse({ - isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, - isDeactivated: status.isDeactivated, - }); - - return configurationStatus; - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts index 2ff671cb62b..8d183c1f88b 100644 --- a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts +++ b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts @@ -1,11 +1,10 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; -import { ToolConfigModule } from '../tool-config.module'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from './service'; @Module({ - imports: [forwardRef(() => CommonToolModule), forwardRef(() => ExternalToolModule), ToolConfigModule], + imports: [forwardRef(() => CommonToolModule), forwardRef(() => ExternalToolModule)], providers: [SchoolExternalToolService, SchoolExternalToolValidationService], exports: [SchoolExternalToolService, SchoolExternalToolValidationService], }) diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts index f0e8f89640f..9f3e50b6b9a 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts @@ -1,14 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApiValidationError } from '@shared/common'; +import { ValidationError } from '@shared/common'; import { SchoolExternalToolRepo } from '@shared/repo'; +import { CommonToolValidationService } from '../../common/service'; import { ExternalToolService } from '../../external-tool'; -import { ExternalTool } from '../../external-tool/domain'; +import { type ExternalTool } from '../../external-tool/domain'; import { externalToolFactory } from '../../external-tool/testing'; -import { SchoolExternalTool } from '../domain'; -import { schoolExternalToolConfigurationStatusFactory, schoolExternalToolFactory } from '../testing'; +import { SchoolExternalTool, SchoolExternalToolConfigurationStatus } from '../domain'; +import { schoolExternalToolFactory } from '../testing'; import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; -import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; import { SchoolExternalToolService } from './school-external-tool.service'; describe(SchoolExternalToolService.name, () => { @@ -17,7 +17,7 @@ describe(SchoolExternalToolService.name, () => { let schoolExternalToolRepo: DeepMocked; let externalToolService: DeepMocked; - let schoolExternalToolValidationService: DeepMocked; + let commonToolValidationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -32,8 +32,8 @@ describe(SchoolExternalToolService.name, () => { useValue: createMock(), }, { - provide: SchoolExternalToolValidationService, - useValue: createMock(), + provide: CommonToolValidationService, + useValue: createMock(), }, ], }).compile(); @@ -41,293 +41,178 @@ describe(SchoolExternalToolService.name, () => { service = module.get(SchoolExternalToolService); schoolExternalToolRepo = module.get(SchoolExternalToolRepo); externalToolService = module.get(ExternalToolService); - schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); + commonToolValidationService = module.get(CommonToolValidationService); }); describe('findSchoolExternalTools', () => { describe('when called with query', () => { - describe('findSchoolExternalTools', () => { - describe('when called with query', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const schoolExternalToolQuery: SchoolExternalToolQuery = { - schoolId: schoolExternalTool.schoolId, - toolId: schoolExternalTool.toolId, - isDeactivated: !!schoolExternalTool.status?.isDeactivated, - }; - - schoolExternalToolRepo.find.mockResolvedValueOnce([schoolExternalTool]); - - return { - schoolExternalTool, - schoolExternalToolId: schoolExternalTool.id, - schoolExternalToolQuery, - }; - }; - - it('should call repo with query', async () => { - const { schoolExternalTool, schoolExternalToolQuery } = setup(); - - await service.findSchoolExternalTools(schoolExternalToolQuery); - - expect(schoolExternalToolRepo.find).toHaveBeenCalledWith<[Required]>({ - schoolId: schoolExternalTool.schoolId, - toolId: schoolExternalTool.toolId, - isDeactivated: !!schoolExternalTool.status?.isDeactivated, - }); - }); - - it('should return schoolExternalTool array', async () => { - const { schoolExternalToolQuery } = setup(); - - const result: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalToolQuery); - - expect(Array.isArray(result)).toBe(true); - }); + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + name: undefined, + status: undefined, }); - }); - describe('enrichDataFromExternalTool', () => { - describe('when schoolExternalTool is given', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalToolQuery: SchoolExternalToolQuery = { + schoolId: schoolExternalTool.schoolId, + toolId: schoolExternalTool.toolId, + isDeactivated: schoolExternalTool.isDeactivated, + }; - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolRepo.find.mockResolvedValueOnce([schoolExternalTool]); + externalToolService.findById.mockResolvedValueOnce(externalTool); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); - return { - schoolExternalTool, - }; - }; + return { + externalTool, + schoolExternalTool, + schoolExternalToolQuery, + }; + }; - it('should call the externalToolService', async () => { - const { schoolExternalTool } = setup(); + it('should call repo with query', async () => { + const { schoolExternalTool, schoolExternalToolQuery } = setup(); - await service.findSchoolExternalTools(schoolExternalTool); + await service.findSchoolExternalTools(schoolExternalToolQuery); - expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); - }); + expect(schoolExternalToolRepo.find).toHaveBeenCalledWith<[Required]>({ + schoolId: schoolExternalTool.schoolId, + toolId: schoolExternalTool.toolId, + isDeactivated: schoolExternalTool.isDeactivated, }); + }); - describe('when determine status', () => { - describe('when validation goes through', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); - schoolExternalToolValidationService.validate.mockResolvedValue(); - - return { - schoolExternalTool, - }; - }; - - it('should return latest tool status', async () => { - const { schoolExternalTool } = setup(); - - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( - schoolExternalTool - ); - - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); - expect(schoolExternalToolDOs[0].status).toEqual( - schoolExternalToolConfigurationStatusFactory.build({ - isOutdatedOnScopeSchool: false, - }) - ); - }); - - it('should return non deactivated tool status', async () => { - const { schoolExternalTool } = setup(); - - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( - schoolExternalTool - ); - - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); - expect(schoolExternalToolDOs[0].status).toEqual( - schoolExternalToolConfigurationStatusFactory.build({ - isDeactivated: false, - }) - ); - }); - }); - - describe('when validation throws error', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); - schoolExternalToolValidationService.validate.mockRejectedValue(ApiValidationError); - - return { - schoolExternalTool, - }; - }; - - it('should return outdated tool status', async () => { - const { schoolExternalTool } = setup(); - - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( - schoolExternalTool - ); - - expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); - expect(schoolExternalToolDOs[0].status).toEqual( - schoolExternalToolConfigurationStatusFactory.build({ - isOutdatedOnScopeSchool: true, - }) - ); - }); - }); - - describe('when schoolExternalTool is deactivated', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - status: schoolExternalToolConfigurationStatusFactory.build({ isDeactivated: true }), - }); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); - schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); - - return { - schoolExternalTool, - }; - }; - - it('should return deactivated tool status true', async () => { - const { schoolExternalTool } = setup(); - - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( - schoolExternalTool - ); - - expect(schoolExternalToolDOs[0].status).toEqual( - schoolExternalToolConfigurationStatusFactory.build({ - isDeactivated: true, - isOutdatedOnScopeSchool: true, - }) - ); - }); - }); - - describe('when externalTool is deactivated', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId({ isDeactivated: true }); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); - schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); - - return { - schoolExternalTool, - }; - }; - - it('should return deactivated tool status true', async () => { - const { schoolExternalTool } = setup(); - - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( - schoolExternalTool - ); - - expect(schoolExternalToolDOs[0].status).toEqual( - schoolExternalToolConfigurationStatusFactory.build({ - isDeactivated: true, - isOutdatedOnScopeSchool: true, - }) - ); - }); - }); - }); + it('should return schoolExternalTool array with enriched data', async () => { + const { schoolExternalToolQuery, schoolExternalTool, externalTool } = setup(); + + const result: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalToolQuery); + + expect(result).toEqual([ + new SchoolExternalTool({ + ...schoolExternalTool.getProps(), + name: externalTool.name, + status: new SchoolExternalToolConfigurationStatus({ + isGloballyDeactivated: externalTool.isDeactivated, + isOutdatedOnScopeSchool: true, + }), + }), + ]); }); + }); + }); - describe('deleteSchoolExternalToolById', () => { - describe('when schoolExternalToolId is given', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + describe('deleteSchoolExternalToolById', () => { + describe('when schoolExternalToolId is given', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); - return { - schoolExternalToolId: schoolExternalTool.id, - }; - }; + return { + schoolExternalToolId: schoolExternalTool.id, + }; + }; - it('should call the schoolExternalToolRepo', () => { - const { schoolExternalToolId } = setup(); + it('should call the schoolExternalToolRepo', () => { + const { schoolExternalToolId } = setup(); - service.deleteSchoolExternalToolById(schoolExternalToolId); + service.deleteSchoolExternalToolById(schoolExternalToolId); - expect(schoolExternalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalToolId); - }); - }); + expect(schoolExternalToolRepo.deleteById).toHaveBeenCalledWith(schoolExternalToolId); }); + }); + }); - describe('findById', () => { - describe('when schoolExternalToolId is given', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); + describe('findById', () => { + describe('when schoolExternalToolId is given', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + name: undefined, + status: undefined, + }); - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolRepo.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); - return { - schoolExternalToolId: schoolExternalTool.id, - }; - }; + return { + schoolExternalTool, + externalTool, + }; + }; - it('should call schoolExternalToolRepo.findById', async () => { - const { schoolExternalToolId } = setup(); + it('should call schoolExternalToolRepo.findById', async () => { + const { schoolExternalTool } = setup(); - await service.findById(schoolExternalToolId); + await service.findById(schoolExternalTool.id); - expect(schoolExternalToolRepo.findById).toHaveBeenCalledWith(schoolExternalToolId); - }); - }); + expect(schoolExternalToolRepo.findById).toHaveBeenCalledWith(schoolExternalTool.id); }); - describe('saveSchoolExternalTool', () => { - describe('when schoolExternalTool is given', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); + it('should return the schoolExternalTool with enriched data', async () => { + const { schoolExternalTool, externalTool } = setup(); + + const result = await service.findById(schoolExternalTool.id); + + expect(result).toEqual( + new SchoolExternalTool({ + ...schoolExternalTool.getProps(), + name: externalTool.name, + status: new SchoolExternalToolConfigurationStatus({ + isGloballyDeactivated: externalTool.isDeactivated, + isOutdatedOnScopeSchool: true, + }), + }) + ); + }); + }); + }); - return { - schoolExternalTool, - }; - }; + describe('saveSchoolExternalTool', () => { + describe('when schoolExternalTool is given', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + name: undefined, + status: undefined, + }); - it('should call schoolExternalToolRepo.save', async () => { - const { schoolExternalTool } = setup(); + schoolExternalToolRepo.save.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); + commonToolValidationService.validateParameters.mockReturnValueOnce([new ValidationError('')]); - await service.saveSchoolExternalTool(schoolExternalTool); + return { + schoolExternalTool, + externalTool, + }; + }; - expect(schoolExternalToolRepo.save).toHaveBeenCalledWith(schoolExternalTool); - }); + it('should call schoolExternalToolRepo.save', async () => { + const { schoolExternalTool } = setup(); - it('should enrich data from externalTool', async () => { - const { schoolExternalTool } = setup(); + await service.saveSchoolExternalTool(schoolExternalTool); - await service.saveSchoolExternalTool(schoolExternalTool); + expect(schoolExternalToolRepo.save).toHaveBeenCalledWith(schoolExternalTool); + }); - expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); - }); - }); + it('should return the schoolExternalTool with enriched data', async () => { + const { schoolExternalTool, externalTool } = setup(); + + const result = await service.saveSchoolExternalTool(schoolExternalTool); + + expect(result).toEqual( + new SchoolExternalTool({ + ...schoolExternalTool.getProps(), + name: externalTool.name, + status: new SchoolExternalToolConfigurationStatus({ + isGloballyDeactivated: externalTool.isDeactivated, + isOutdatedOnScopeSchool: true, + }), + }) + ); }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index 0a05ceb5685..978f0aa4ee5 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts @@ -1,23 +1,25 @@ import { Injectable } from '@nestjs/common'; +import { ValidationError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { SchoolExternalToolRepo } from '@shared/repo'; +import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; -import { SchoolExternalToolConfigurationStatus } from '../controller/dto'; -import { SchoolExternalTool } from '../domain'; +import { SchoolExternalTool, SchoolExternalToolConfigurationStatus } from '../domain'; import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; -import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; @Injectable() export class SchoolExternalToolService { constructor( private readonly schoolExternalToolRepo: SchoolExternalToolRepo, private readonly externalToolService: ExternalToolService, - private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService + private readonly commonToolValidationService: CommonToolValidationService ) {} public async findById(schoolExternalToolId: EntityId): Promise { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.findById(schoolExternalToolId); + let schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.findById(schoolExternalToolId); + + schoolExternalTool = await this.enrichWithDataFromExternalTool(schoolExternalTool); return schoolExternalTool; } @@ -36,45 +38,45 @@ export class SchoolExternalToolService { private async enrichWithDataFromExternalTools(tools: SchoolExternalTool[]): Promise { const enrichedTools: SchoolExternalTool[] = await Promise.all( - tools.map(async (tool: SchoolExternalTool): Promise => this.enrichDataFromExternalTool(tool)) + tools.map( + async (tool: SchoolExternalTool): Promise => this.enrichWithDataFromExternalTool(tool) + ) ); return enrichedTools; } - private async enrichDataFromExternalTool(tool: SchoolExternalTool): Promise { + private async enrichWithDataFromExternalTool(tool: SchoolExternalTool): Promise { const externalTool: ExternalTool = await this.externalToolService.findById(tool.toolId); - const status: SchoolExternalToolConfigurationStatus = await this.determineSchoolToolStatus(tool, externalTool); + const status: SchoolExternalToolConfigurationStatus = this.determineSchoolToolStatus(tool, externalTool); + const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ - id: tool.id, - toolId: tool.toolId, - schoolId: tool.schoolId, - parameters: tool.parameters, - status, + ...tool.getProps(), name: externalTool.name, + status, }); return schoolExternalTool; } - private async determineSchoolToolStatus( + private determineSchoolToolStatus( tool: SchoolExternalTool, externalTool: ExternalTool - ): Promise { - const status: SchoolExternalToolConfigurationStatus = new SchoolExternalToolConfigurationStatus({ - isOutdatedOnScopeSchool: true, - isDeactivated: this.isToolDeactivated(externalTool, tool), - }); + ): SchoolExternalToolConfigurationStatus { + let isOutdatedOnScopeSchool = false; - try { - await this.schoolExternalToolValidationService.validate(tool); + const errors: ValidationError[] = this.commonToolValidationService.validateParameters(externalTool, tool); - status.isOutdatedOnScopeSchool = false; - - return status; - } catch (err) { - return status; + if (errors.length) { + isOutdatedOnScopeSchool = true; } + + const status: SchoolExternalToolConfigurationStatus = new SchoolExternalToolConfigurationStatus({ + isOutdatedOnScopeSchool, + isGloballyDeactivated: externalTool.isDeactivated, + }); + + return status; } public deleteSchoolExternalToolById(schoolExternalToolId: EntityId): void { @@ -83,16 +85,9 @@ export class SchoolExternalToolService { public async saveSchoolExternalTool(schoolExternalTool: SchoolExternalTool): Promise { let createdSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.save(schoolExternalTool); - createdSchoolExternalTool = await this.enrichDataFromExternalTool(createdSchoolExternalTool); - - return createdSchoolExternalTool; - } - private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool) { - if (externalTool.isDeactivated || schoolExternalTool.status?.isDeactivated) { - return true; - } + createdSchoolExternalTool = await this.enrichWithDataFromExternalTool(createdSchoolExternalTool); - return false; + return createdSchoolExternalTool; } } diff --git a/apps/server/src/modules/tool/school-external-tool/testing/index.ts b/apps/server/src/modules/tool/school-external-tool/testing/index.ts index 2c15f549607..55b1de1dbfa 100644 --- a/apps/server/src/modules/tool/school-external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/index.ts @@ -2,4 +2,3 @@ export { schoolExternalToolEntityFactory } from './school-external-tool-entity.f export { schoolExternalToolFactory } from './school-external-tool.factory'; export { schoolExternalToolConfigurationStatusFactory } from './school-external-tool-configuration-status.factory'; export { schoolExternalToolConfigurationStatusResponseFactory } from './school-external-tool-configuration-status-response.factory'; -export { schoolExternalToolConfigurationStatusEntityFactory } from './school-external-tool-configuration-status-entity.factory'; diff --git a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-entity.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-entity.factory.ts deleted file mode 100644 index 73b02ad773d..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-entity.factory.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Factory } from 'fishery'; -import { SchoolExternalToolConfigurationStatusEntity } from '../entity/school-external-tool-configuration-status.entity'; - -export const schoolExternalToolConfigurationStatusEntityFactory = - Factory.define(() => { - return { - isOutdatedOnScopeSchool: false, - isDeactivated: false, - }; - }); diff --git a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-response.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-response.factory.ts index a381fadc03b..0890f40227d 100644 --- a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-response.factory.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status-response.factory.ts @@ -5,6 +5,6 @@ export const schoolExternalToolConfigurationStatusResponseFactory = Factory.define(() => { return { isOutdatedOnScopeSchool: false, - isDeactivated: false, + isGloballyDeactivated: false, }; }); diff --git a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status.factory.ts index 11b38da4bbd..f925bea5a45 100644 --- a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status.factory.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-configuration-status.factory.ts @@ -1,11 +1,11 @@ import { Factory } from 'fishery'; -import { SchoolExternalToolConfigurationStatus } from '../controller/dto'; +import { SchoolExternalToolConfigurationStatus } from '../domain'; export const schoolExternalToolConfigurationStatusFactory = Factory.define( () => { return { isOutdatedOnScopeSchool: false, - isDeactivated: false, + isGloballyDeactivated: false, }; } ); diff --git a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts index a90ceafd0ba..383aabbb407 100644 --- a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts @@ -2,7 +2,6 @@ import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { SchoolExternalToolEntity, SchoolExternalToolEntityProps } from '@modules/tool/school-external-tool/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; import { schoolEntityFactory } from '@shared/testing/factory/school-entity.factory'; -import { schoolExternalToolConfigurationStatusEntityFactory } from './school-external-tool-configuration-status-entity.factory'; export const schoolExternalToolEntityFactory = BaseFactory.define< SchoolExternalToolEntity, @@ -12,6 +11,6 @@ export const schoolExternalToolEntityFactory = BaseFactory.define< tool: externalToolEntityFactory.buildWithId(), school: schoolEntityFactory.buildWithId(), schoolParameters: [{ name: 'schoolMockParameter', value: 'mockValue' }], - status: schoolExternalToolConfigurationStatusEntityFactory.build(), + isDeactivated: false, }; }); diff --git a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool.factory.ts index a7af0be4b53..3d979b90331 100644 --- a/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool.factory.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool.factory.ts @@ -26,6 +26,7 @@ export const schoolExternalToolFactory = SchoolExternalToolFactory.define(School }), ], toolId: 'toolId', + isDeactivated: false, status: schoolExternalToolConfigurationStatusFactory.build(), }; }); diff --git a/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts b/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts index 48ba154dab0..c66f31feb57 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/dto/school-external-tool.types.ts @@ -1,7 +1,3 @@ -import { SchoolExternalToolProps } from '../../domain'; - -export type SchoolExternalToolDto = SchoolExternalToolProps; - export type SchoolExternalToolQueryInput = { schoolId?: string; toolId?: string; diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts index d8d4c95405c..c7861f82823 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts @@ -6,9 +6,9 @@ import { EntityId } from '@shared/domain/types'; import { School, SchoolService } from '@src/modules/school'; import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalTool, SchoolExternalToolMetadata } from '../domain'; +import { SchoolExternalTool, SchoolExternalToolMetadata, SchoolExternalToolProps } from '../domain'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; -import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; +import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; @Injectable() export class SchoolExternalToolUc { @@ -38,7 +38,7 @@ export class SchoolExternalToolUc { async createSchoolExternalTool( userId: EntityId, - schoolExternalToolDto: SchoolExternalToolDto + schoolExternalToolDto: SchoolExternalToolProps ): Promise { const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); @@ -96,7 +96,7 @@ export class SchoolExternalToolUc { async updateSchoolExternalTool( userId: EntityId, schoolExternalToolId: string, - schoolExternalToolDto: SchoolExternalToolDto + schoolExternalToolDto: SchoolExternalToolProps ): Promise { const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index 067f062e7f3..12d33bd3d90 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -18,9 +18,7 @@ import { ExternalToolRequestMapper, ExternalToolResponseMapper } from './externa import { ExternalToolConfigurationService } from './external-tool/service'; import { ExternalToolConfigurationUc, ExternalToolUc } from './external-tool/uc'; import { ToolSchoolController } from './school-external-tool/controller'; -import { SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper } from './school-external-tool/mapper'; import { SchoolExternalToolUc } from './school-external-tool/uc'; -import { ToolConfigModule } from './tool-config.module'; import { ToolLaunchController } from './tool-launch/controller/tool-launch.controller'; import { ToolLaunchUc } from './tool-launch/uc'; import { ToolModule } from './tool.module'; @@ -33,7 +31,6 @@ import { ToolModule } from './tool.module'; AuthorizationModule, LoggerModule, LegacySchoolModule, - ToolConfigModule, LearnroomModule, BoardModule, SchoolModule, @@ -55,8 +52,6 @@ import { ToolModule } from './tool.module'; ExternalToolRequestMapper, ExternalToolResponseMapper, SchoolExternalToolUc, - SchoolExternalToolResponseMapper, - SchoolExternalToolRequestMapper, ContextExternalToolUc, ToolLaunchUc, ToolReferenceUc, diff --git a/apps/server/src/modules/tool/tool-config.module.ts b/apps/server/src/modules/tool/tool-config.module.ts deleted file mode 100644 index f30458519f5..00000000000 --- a/apps/server/src/modules/tool/tool-config.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import ToolConfiguration, { ToolFeatures } from './tool-config'; - -@Module({ - providers: [ - { - provide: ToolFeatures, - useValue: ToolConfiguration.toolFeatures, - }, - ], - exports: [ToolFeatures], -}) -export class ToolConfigModule {} diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index ccd33a579ce..98ec436a322 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -1,23 +1,8 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; - -export const ToolFeatures = Symbol('ToolFeatures'); - -export interface IToolFeatures { - ctlToolsTabEnabled: boolean; - ltiToolsTabEnabled: boolean; - maxExternalToolLogoSizeInBytes: number; - backEndUrl: string; - ctlToolsCopyEnabled: boolean; - ctlToolsReloadTimeMs: number; -} - -export default class ToolConfiguration { - static toolFeatures: IToolFeatures = { - ctlToolsTabEnabled: Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED') as boolean, - ltiToolsTabEnabled: Configuration.get('FEATURE_LTI_TOOLS_TAB_ENABLED') as boolean, - maxExternalToolLogoSizeInBytes: Configuration.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') as number, - backEndUrl: Configuration.get('PUBLIC_BACKEND_URL') as string, - ctlToolsCopyEnabled: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean, - ctlToolsReloadTimeMs: Configuration.get('CTL_TOOLS_RELOAD_TIME_MS') as number, - }; +export interface ToolConfig { + FEATURE_CTL_TOOLS_TAB_ENABLED: boolean; + FEATURE_LTI_TOOLS_TAB_ENABLED: boolean; + CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES: number; + CTL_TOOLS_BACKEND_URL: string; + FEATURE_CTL_TOOLS_COPY_ENABLED: boolean; + CTL_TOOLS_RELOAD_TIME_MS: number; } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index f0e3aec8e24..f85cfcd69a3 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -25,10 +25,7 @@ import { externalToolEntityFactory, } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; -import { - schoolExternalToolConfigurationStatusEntityFactory, - schoolExternalToolEntityFactory, -} from '../../../school-external-tool/testing'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { LaunchRequestMethod } from '../../types'; import { ContextExternalToolBodyParams, ContextExternalToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; @@ -242,9 +239,7 @@ describe('ToolLaunchController (API)', () => { const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school, - status: schoolExternalToolConfigurationStatusEntityFactory.build({ - isDeactivated: true, - }), + isDeactivated: true, }); const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalToolEntity, diff --git a/apps/server/src/modules/tool/tool.module.ts b/apps/server/src/modules/tool/tool.module.ts index 91a19c5c995..b0ba0d429fe 100644 --- a/apps/server/src/modules/tool/tool.module.ts +++ b/apps/server/src/modules/tool/tool.module.ts @@ -1,15 +1,13 @@ import { forwardRef, Module } from '@nestjs/common'; +import { CommonToolModule } from './common'; +import { CommonToolService } from './common/service'; import { ContextExternalToolModule } from './context-external-tool'; -import { SchoolExternalToolModule } from './school-external-tool'; import { ExternalToolModule } from './external-tool'; -import { CommonToolModule } from './common'; +import { SchoolExternalToolModule } from './school-external-tool'; import { ToolLaunchModule } from './tool-launch'; -import { CommonToolService } from './common/service'; -import { ToolConfigModule } from './tool-config.module'; @Module({ imports: [ - ToolConfigModule, forwardRef(() => CommonToolModule), ExternalToolModule, SchoolExternalToolModule, diff --git a/apps/server/src/modules/user-import/config/index.ts b/apps/server/src/modules/user-import/config/index.ts deleted file mode 100644 index 3267752a160..00000000000 --- a/apps/server/src/modules/user-import/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UserImportFeatures, UserImportConfiguration, IUserImportFeatures } from './user-import-config'; diff --git a/apps/server/src/modules/user-import/config/user-import-config.ts b/apps/server/src/modules/user-import/config/user-import-config.ts deleted file mode 100644 index 05cd6265a87..00000000000 --- a/apps/server/src/modules/user-import/config/user-import-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; - -export const UserImportFeatures = Symbol('UserImportFeatures'); - -export interface IUserImportFeatures { - userMigrationEnabled: boolean; - userMigrationSystemId: string; - useWithUserLoginMigration: boolean; -} - -export class UserImportConfiguration { - static userImportFeatures: IUserImportFeatures = { - userMigrationEnabled: Configuration.get('FEATURE_USER_MIGRATION_ENABLED') as boolean, - userMigrationSystemId: Configuration.get('FEATURE_USER_MIGRATION_SYSTEM_ID') as string, - useWithUserLoginMigration: Configuration.get('FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION') as boolean, - }; -} diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts index 2da305760de..21b9b6b005a 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user-populate.api.spec.ts @@ -1,6 +1,7 @@ import { SchulconnexResponse, schulconnexResponseFactory } from '@infra/schulconnex-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; +import { serverConfig, ServerConfig } from '@modules/server'; import { ServerTestModule } from '@modules/server/server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -10,14 +11,12 @@ import { roleFactory, schoolEntityFactory, systemEntityFactory, TestApiClient, u import { accountFactory } from '@src/modules/account/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { IUserImportFeatures, UserImportFeatures } from '../../config'; describe('ImportUser Controller Populate (API)', () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; - let userImportFeatures: IUserImportFeatures; let axiosMock: MockAdapter; const authenticatedUser = async ( @@ -42,9 +41,10 @@ describe('ImportUser Controller Populate (API)', () => { }; const setConfig = (systemId?: string) => { - userImportFeatures.userMigrationEnabled = true; - userImportFeatures.userMigrationSystemId = systemId || new ObjectId().toString(); - userImportFeatures.useWithUserLoginMigration = false; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = true; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = systemId || new ObjectId().toString(); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = false; }; beforeAll(async () => { @@ -58,7 +58,6 @@ describe('ImportUser Controller Populate (API)', () => { em = app.get(EntityManager); testApiClient = new TestApiClient(app, 'user/import'); - userImportFeatures = app.get(UserImportFeatures); axiosMock = new MockAdapter(axios); }); @@ -90,7 +89,8 @@ describe('ImportUser Controller Populate (API)', () => { const { account } = await authenticatedUser([Permission.IMPORT_USER_MIGRATE]); const loggedInClient = await testApiClient.login(account); - userImportFeatures.userMigrationEnabled = false; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = false; return { loggedInClient }; }; @@ -113,7 +113,8 @@ describe('ImportUser Controller Populate (API)', () => { const setup = async () => { const { account, school, system } = await authenticatedUser([Permission.IMPORT_USER_MIGRATE], [], false); const loggedInClient = await testApiClient.login(account); - userImportFeatures.userMigrationSystemId = system.id; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system.id; school.externalId = undefined; @@ -139,8 +140,9 @@ describe('ImportUser Controller Populate (API)', () => { const { account, school, system } = await authenticatedUser([Permission.IMPORT_USER_MIGRATE]); const loggedInClient = await testApiClient.login(account); - userImportFeatures.userMigrationEnabled = true; - userImportFeatures.userMigrationSystemId = system.id; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = true; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system.id; axiosMock.onPost(/(.*)\/token/).reply(HttpStatus.OK, { id_token: 'idToken', 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 e3f091139c7..c0aa9b06e36 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 @@ -1,5 +1,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { serverConfig, ServerConfig } from '@modules/server'; import { ServerTestModule } from '@modules/server/server.module'; +import { SystemEntity } from '@modules/system/entity'; import { FilterImportUserParams, FilterMatchType, @@ -19,7 +21,7 @@ import { import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PaginationParams } from '@shared/controller'; -import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; import { @@ -34,14 +36,12 @@ import { } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { accountFactory } from '@src/modules/account/testing'; -import { IUserImportFeatures, UserImportFeatures } from '../../config'; describe('ImportUser Controller (API)', () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; - let userImportFeatures: IUserImportFeatures; const authenticatedUser = async ( permissions: Permission[] = [], @@ -66,9 +66,10 @@ describe('ImportUser Controller (API)', () => { }; const setConfig = (systemId?: string) => { - userImportFeatures.userMigrationEnabled = true; - userImportFeatures.userMigrationSystemId = systemId || new ObjectId().toString(); - userImportFeatures.useWithUserLoginMigration = false; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = true; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = systemId || new ObjectId().toString(); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = false; }; beforeAll(async () => { @@ -82,7 +83,6 @@ describe('ImportUser Controller (API)', () => { em = app.get(EntityManager); testApiClient = new TestApiClient(app, 'user/import'); - userImportFeatures = app.get(UserImportFeatures); }); afterAll(async () => { @@ -115,8 +115,11 @@ describe('ImportUser Controller (API)', () => { Permission.IMPORT_USER_VIEW, ])); testApiClient = await testApiClient.login(account); - userImportFeatures.userMigrationEnabled = false; - userImportFeatures.userMigrationSystemId = ''; + + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = false; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = ''; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = false; }); afterEach(() => { @@ -172,7 +175,8 @@ describe('ImportUser Controller (API)', () => { beforeEach(async () => { ({ account, system } = await authenticatedUser()); testApiClient = await testApiClient.login(account); - userImportFeatures.userMigrationSystemId = system._id.toString(); + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system._id.toString(); }); it('GET /user/import is UNAUTHORIZED', async () => { @@ -224,8 +228,9 @@ describe('ImportUser Controller (API)', () => { [SchoolFeature.LDAP_UNIVENTION_MIGRATION] )); testApiClient = await testApiClient.login(account); - userImportFeatures.userMigrationSystemId = system._id.toString(); - userImportFeatures.userMigrationEnabled = false; + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_ENABLED = false; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system._id.toString(); }); it('GET user/import is authorized, despite feature not enabled', async () => { @@ -243,7 +248,8 @@ describe('ImportUser Controller (API)', () => { beforeEach(async () => { ({ school, system, account } = await authenticatedUser([Permission.IMPORT_USER_VIEW])); testApiClient = await testApiClient.login(account); - userImportFeatures.userMigrationSystemId = system._id.toString(); + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system._id.toString(); }); it('GET /user/import responds with importusers', async () => { @@ -1081,7 +1087,8 @@ describe('ImportUser Controller (API)', () => { it('should set in user migration mode', async () => { ({ account, system } = await authenticatedUser([Permission.IMPORT_USER_MIGRATE])); testApiClient = await testApiClient.login(account); - userImportFeatures.userMigrationSystemId = system._id.toString(); + const config: ServerConfig = serverConfig(); + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system._id.toString(); await testApiClient.post('startUserMigration').expect(HttpStatus.CREATED); }); diff --git a/apps/server/src/modules/user-import/index.ts b/apps/server/src/modules/user-import/index.ts index b6cd58577bf..e149f9d70bf 100644 --- a/apps/server/src/modules/user-import/index.ts +++ b/apps/server/src/modules/user-import/index.ts @@ -1,3 +1,3 @@ export { ImportUserModule } from './user-import.module'; -export { UserImportConfigModule } from './user-import-config.module'; -export { IUserImportFeatures, UserImportConfiguration } from './config'; +export { UserImportConfig } from './user-import-config'; +export { UserImportService } from './service'; diff --git a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts index a17b555bc3a..4f4f482b8e8 100644 --- a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts @@ -1,13 +1,17 @@ import { SchulconnexGroupType, SchulconnexGruppenResponse, SchulconnexResponse } from '@infra/schulconnex-client'; +import { EntityManager } from '@mikro-orm/mongodb'; import { SchulconnexResponseMapper } from '@modules/provisioning'; -import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { System } from '@modules/system'; +import { SystemEntity } from '@modules/system/entity'; +import { ImportUser, SchoolEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; export class SchulconnexImportUserMapper { public static mapDataToUserImportEntities( response: SchulconnexResponse[], - system: SystemEntity, - school: SchoolEntity + system: System, + school: SchoolEntity, + em: EntityManager ): ImportUser[] { const importUsers: ImportUser[] = response.map((externalUser: SchulconnexResponse): ImportUser => { const role: RoleName = SchulconnexResponseMapper.mapSanisRoleToRoleName(externalUser); @@ -16,7 +20,7 @@ export class SchulconnexImportUserMapper { ); const importUser: ImportUser = new ImportUser({ - system, + system: em.getReference(SystemEntity, system.id), school, ldapDn: `uid=${externalUser.person.name.vorname}.${externalUser.person.name.familienname}.${externalUser.pid},`, externalId: externalUser.pid, diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts index 839783e63c8..2c9d6e8d848 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts @@ -1,15 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { SchulconnexResponse, schulconnexResponseFactory, SchulconnexRestClient } from '@infra/schulconnex-client'; +import type { System } from '@modules/system'; +import { SystemEntity } from '@modules/system/entity'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; -import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { ImportUser, SchoolEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { importUserFactory, schoolEntityFactory, setupEntities, systemEntityFactory, + systemFactory, userDoFactory, } from '@shared/testing'; import { UserImportSchoolExternalIdMissingLoggableException } from '../loggable'; @@ -26,6 +30,7 @@ describe(SchulconnexFetchImportUsersService.name, () => { await setupEntities(); module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], providers: [ SchulconnexFetchImportUsersService, { @@ -73,12 +78,13 @@ describe(SchulconnexFetchImportUsersService.name, () => { describe('when fetching the data', () => { const setup = () => { const externalUserData: SchulconnexResponse = schulconnexResponseFactory.build(); - const system: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); const school: SchoolEntity = schoolEntityFactory.buildWithId({ - systems: [system], + systems: [systemEntity], externalId: 'externalSchoolId', }); - const importUser: ImportUser = createImportUser(externalUserData, school, system); + const importUser: ImportUser = createImportUser(externalUserData, school, systemEntity); schulconnexRestClient.getPersonenInfo.mockResolvedValueOnce([externalUserData]); @@ -111,9 +117,8 @@ describe(SchulconnexFetchImportUsersService.name, () => { describe('when the school has no external id', () => { const setup = () => { - const system: SystemEntity = systemEntityFactory.buildWithId(); + const system: System = systemFactory.build(); const school: SchoolEntity = schoolEntityFactory.buildWithId({ - systems: [system], externalId: undefined, }); diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts index c5a3f7815eb..2a49438a7c3 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts @@ -1,8 +1,10 @@ import { SchulconnexResponse, SchulconnexRestClient } from '@infra/schulconnex-client'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { System } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject'; -import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { ImportUser, SchoolEntity } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { UserImportSchoolExternalIdMissingLoggableException } from '../loggable'; import { SchulconnexImportUserMapper } from '../mapper'; @@ -11,10 +13,11 @@ import { SchulconnexImportUserMapper } from '../mapper'; export class SchulconnexFetchImportUsersService { constructor( private readonly schulconnexRestClient: SchulconnexRestClient, - private readonly userService: UserService + private readonly userService: UserService, + private readonly em: EntityManager ) {} - public async getData(school: SchoolEntity, system: SystemEntity): Promise { + public async getData(school: SchoolEntity, system: System): Promise { const externalSchoolId: string | undefined = school.externalId; if (!externalSchoolId) { throw new UserImportSchoolExternalIdMissingLoggableException(school.id); @@ -28,7 +31,8 @@ export class SchulconnexFetchImportUsersService { const mappedImportUsers: ImportUser[] = SchulconnexImportUserMapper.mapDataToUserImportEntities( response, system, - school + school, + this.em ); return mappedImportUsers; diff --git a/apps/server/src/modules/user-import/service/user-import.service.spec.ts b/apps/server/src/modules/user-import/service/user-import.service.spec.ts index 397d042eaea..87d683f2868 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.spec.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.spec.ts @@ -1,25 +1,28 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { System, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; import { InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo, LegacySystemRepo } from '@shared/repo'; +import { ImportUserRepo } from '@shared/repo'; import { cleanupCollections, importUserFactory, legacySchoolDoFactory, schoolEntityFactory, setupEntities, - systemEntityFactory, + systemFactory, userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; -import { UserMigrationIsNotEnabled } from '../loggable'; +import { UserMigrationCanceledLoggable, UserMigrationIsNotEnabled } from '../loggable'; +import { UserImportConfig } from '../user-import-config'; import { UserImportService } from './user-import.service'; describe(UserImportService.name, () => { @@ -28,14 +31,16 @@ describe(UserImportService.name, () => { let em: EntityManager; let importUserRepo: DeepMocked; - let legacySystemRepo: DeepMocked; + let systemService: DeepMocked; let userService: DeepMocked; let logger: DeepMocked; + let schoolService: DeepMocked; - const features: IUserImportFeatures = { - userMigrationSystemId: new ObjectId().toHexString(), - userMigrationEnabled: true, - useWithUserLoginMigration: true, + const config: UserImportConfig = { + FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), + FEATURE_USER_MIGRATION_ENABLED: true, + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, + IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 8000, }; beforeAll(async () => { @@ -45,35 +50,42 @@ describe(UserImportService.name, () => { imports: [MongoMemoryDatabaseModule.forRoot()], providers: [ UserImportService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof UserImportConfig) => config[key]), + }, + }, { provide: ImportUserRepo, useValue: createMock(), }, { - provide: LegacySystemRepo, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: UserService, useValue: createMock(), }, - { - provide: UserImportFeatures, - useValue: features, - }, { provide: Logger, useValue: createMock(), }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, ], }).compile(); service = module.get(UserImportService); em = module.get(EntityManager); importUserRepo = module.get(ImportUserRepo); - legacySystemRepo = module.get(LegacySystemRepo); + systemService = module.get(SystemService); userService = module.get(UserService); logger = module.get(Logger); + schoolService = module.get(LegacySchoolService); }); afterAll(async () => { @@ -108,9 +120,9 @@ describe(UserImportService.name, () => { describe('getMigrationSystem', () => { describe('when fetching the migration system', () => { const setup = () => { - const system: SystemEntity = systemEntityFactory.buildWithId(undefined, features.userMigrationSystemId); + const system: System = systemFactory.build(); - legacySystemRepo.findById.mockResolvedValueOnce(system); + systemService.findByIdOrFail.mockResolvedValueOnce(system); return { system, @@ -120,7 +132,7 @@ describe(UserImportService.name, () => { it('should return the system', async () => { const { system } = setup(); - const result: SystemEntity = await service.getMigrationSystem(); + const result: System = await service.getMigrationSystem(); expect(result).toEqual(system); }); @@ -130,7 +142,7 @@ describe(UserImportService.name, () => { describe('checkFeatureEnabled', () => { describe('when the global feature is enabled', () => { const setup = () => { - features.userMigrationEnabled = true; + config.FEATURE_USER_MIGRATION_ENABLED = true; const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: undefined }); @@ -148,7 +160,7 @@ describe(UserImportService.name, () => { describe('when the school feature is enabled', () => { const setup = () => { - features.userMigrationEnabled = false; + config.FEATURE_USER_MIGRATION_ENABLED = false; const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: [SchoolFeature.LDAP_UNIVENTION_MIGRATION], @@ -166,9 +178,9 @@ describe(UserImportService.name, () => { }); }); - describe('when the features are disabled', () => { + describe('when the config are disabled', () => { const setup = () => { - features.userMigrationEnabled = false; + config.FEATURE_USER_MIGRATION_ENABLED = false; const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: [], @@ -356,4 +368,49 @@ describe(UserImportService.name, () => { }); }); }); + + describe('resetMigrationForUsersSchool', () => { + describe('when resetting the migration for a school', () => { + const setup = () => { + const currentUser: User = userFactory.build(); + const school: LegacySchoolDo = legacySchoolDoFactory.build(); + + return { + currentUser, + school, + }; + }; + + it('should delete import users for school', async () => { + const { currentUser, school } = setup(); + + await service.resetMigrationForUsersSchool(currentUser, school); + + expect(importUserRepo.deleteImportUsersBySchool).toHaveBeenCalledWith(currentUser.school); + }); + + it('should save school with reset migration flags', async () => { + const { currentUser, school } = setup(); + + await service.resetMigrationForUsersSchool(currentUser, school); + + expect(schoolService.save).toHaveBeenCalledWith( + { + ...school, + inUserMigration: undefined, + inMaintenanceSince: undefined, + }, + true + ); + }); + + it('should log canceled migration', async () => { + const { currentUser, school } = setup(); + + await service.resetMigrationForUsersSchool(currentUser, school); + + expect(logger.notice).toHaveBeenCalledWith(new UserMigrationCanceledLoggable(expect.any(LegacySchoolDo))); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-import/service/user-import.service.ts b/apps/server/src/modules/user-import/service/user-import.service.ts index 0e165d4828c..db3fbeb5c70 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.ts @@ -1,37 +1,41 @@ -import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { System, SystemService } from '@modules/system'; +import { UserService } from '@modules/user'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo, LegacySystemRepo } from '@shared/repo'; -import { UserService } from '@modules/user'; +import { ImportUserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; -import { UserMigrationIsNotEnabled } from '../loggable'; +import { UserMigrationCanceledLoggable, UserMigrationIsNotEnabled } from '../loggable'; +import { UserImportConfig } from '../user-import-config'; @Injectable() export class UserImportService { constructor( + private readonly configService: ConfigService, private readonly userImportRepo: ImportUserRepo, - private readonly systemRepo: LegacySystemRepo, + private readonly systemService: SystemService, private readonly userService: UserService, - @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, - private readonly logger: Logger + private readonly logger: Logger, + private readonly schoolService: LegacySchoolService ) {} public async saveImportUsers(importUsers: ImportUser[]): Promise { await this.userImportRepo.saveImportUsers(importUsers); } - public async getMigrationSystem(): Promise { - const systemId: string = this.userImportFeatures.userMigrationSystemId; + public async getMigrationSystem(): Promise { + const systemId: string = this.configService.get('FEATURE_USER_MIGRATION_SYSTEM_ID'); - const system: SystemEntity = await this.systemRepo.findById(systemId); + const system: System = await this.systemService.findByIdOrFail(systemId); return system; } public checkFeatureEnabled(school: LegacySchoolDo): void { - const enabled = this.userImportFeatures.userMigrationEnabled; + const enabled = this.configService.get('FEATURE_USER_MIGRATION_ENABLED'); const isLdapPilotSchool = school.features && school.features.includes(SchoolFeature.LDAP_UNIVENTION_MIGRATION); if (!enabled && !isLdapPilotSchool) { @@ -74,4 +78,15 @@ export class UserImportService { public async deleteImportUsersBySchool(school: SchoolEntity): Promise { await this.userImportRepo.deleteImportUsersBySchool(school); } + + public async resetMigrationForUsersSchool(currentUser: User, school: LegacySchoolDo): Promise { + await this.userImportRepo.deleteImportUsersBySchool(currentUser.school); + + school.inUserMigration = undefined; + school.inMaintenanceSince = undefined; + + await this.schoolService.save(school, true); + + this.logger.notice(new UserMigrationCanceledLoggable(school)); + } } diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts index e741b83316c..8394306979f 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts @@ -1,13 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationService } from '@modules/authorization'; +import { ConfigService } from '@nestjs/config'; +import { System } from '@modules/system'; +import { SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; -import { ImportUser, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { importUserFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { importUserFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; import { UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; +import { UserImportConfig } from '../user-import-config'; import { UserImportFetchUc } from './user-import-fetch.uc'; describe(UserImportFetchUc.name, () => { @@ -17,7 +20,13 @@ describe(UserImportFetchUc.name, () => { let schulconnexFetchImportUsersService: DeepMocked; let authorizationService: DeepMocked; let userImportService: DeepMocked; - let userImportFeatures: IUserImportFeatures; + + const config: UserImportConfig = { + FEATURE_USER_MIGRATION_ENABLED: true, + FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: true, + IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 0, + }; beforeAll(async () => { await setupEntities(); @@ -26,8 +35,10 @@ describe(UserImportFetchUc.name, () => { providers: [ UserImportFetchUc, { - provide: UserImportFeatures, - useValue: {}, + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof UserImportConfig) => config[key]), + }, }, { provide: SchulconnexFetchImportUsersService, @@ -48,15 +59,12 @@ describe(UserImportFetchUc.name, () => { schulconnexFetchImportUsersService = module.get(SchulconnexFetchImportUsersService); authorizationService = module.get(AuthorizationService); userImportService = module.get(UserImportService); - userImportFeatures = module.get(UserImportFeatures); }); beforeEach(() => { - Object.assign(userImportFeatures, { - userMigrationEnabled: true, - userMigrationSystemId: new ObjectId().toHexString(), - useWithUserLoginMigration: true, - }); + config.FEATURE_USER_MIGRATION_ENABLED = true; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = new ObjectId().toHexString(); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; }); afterAll(async () => { @@ -72,15 +80,16 @@ describe(UserImportFetchUc.name, () => { const setup = () => { const system: SystemEntity = systemEntityFactory.buildWithId( undefined, - userImportFeatures.userMigrationSystemId + config.FEATURE_USER_MIGRATION_SYSTEM_ID ); + const systemDo: System = systemFactory.build({ id: system.id }); const user: User = userFactory.buildWithId(); const importUser: ImportUser = importUserFactory.build({ system, }); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - userImportService.getMigrationSystem.mockResolvedValueOnce(system); + userImportService.getMigrationSystem.mockResolvedValueOnce(systemDo); schulconnexFetchImportUsersService.getData.mockResolvedValueOnce([importUser]); schulconnexFetchImportUsersService.filterAlreadyMigratedUser.mockResolvedValueOnce([importUser]); userImportService.matchUsers.mockResolvedValueOnce([importUser]); @@ -139,7 +148,7 @@ describe(UserImportFetchUc.name, () => { describe('when the migration feature is not enabled', () => { const setup = () => { - userImportFeatures.userMigrationEnabled = false; + config.FEATURE_USER_MIGRATION_ENABLED = false; const user: User = userFactory.buildWithId(); @@ -157,7 +166,7 @@ describe(UserImportFetchUc.name, () => { describe('when the target system id is not defined', () => { const setup = () => { - userImportFeatures.userMigrationSystemId = ''; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = ''; const user: User = userFactory.buildWithId(); diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts index d68c6e2cdfa..743e942449d 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts @@ -1,16 +1,18 @@ import { AuthorizationService } from '@modules/authorization'; -import { Inject, Injectable } from '@nestjs/common'; -import { ImportUser, SystemEntity, User } from '@shared/domain/entity'; +import { System } from '@modules/system'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ImportUser, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; import { UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; +import { UserImportConfig } from '../user-import-config'; @Injectable() export class UserImportFetchUc { constructor( - @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, + private readonly configService: ConfigService, private readonly schulconnexFetchImportUsersService: SchulconnexFetchImportUsersService, private readonly authorizationService: AuthorizationService, private readonly userImportService: UserImportService @@ -22,12 +24,12 @@ export class UserImportFetchUc { const user: User = await this.authorizationService.getUserWithPermissions(currentUserId); this.authorizationService.checkAllPermissions(user, [Permission.IMPORT_USER_MIGRATE]); - const system: SystemEntity = await this.userImportService.getMigrationSystem(); + const system: System = await this.userImportService.getMigrationSystem(); const fetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.getData(user.school, system); const filteredFetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.filterAlreadyMigratedUser( fetchedData, - this.userImportFeatures.userMigrationSystemId + this.configService.get('FEATURE_USER_MIGRATION_SYSTEM_ID') ); const matchedImportUsers: ImportUser[] = await this.userImportService.matchUsers(filteredFetchedData); @@ -38,7 +40,10 @@ export class UserImportFetchUc { } private checkMigrationEnabled(userId: EntityId): void { - if (!this.userImportFeatures.userMigrationEnabled || !this.userImportFeatures.userMigrationSystemId) { + if ( + !this.configService.get('FEATURE_USER_MIGRATION_ENABLED') || + !this.configService.get('FEATURE_USER_MIGRATION_SYSTEM_ID') + ) { throw new UserMigrationIsNotEnabledLoggableException(userId); } } diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index 81ed9b6502f..5b22368f4f3 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 @@ -3,38 +3,37 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Account, AccountService } from '@modules/account'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; +import { System, SystemService } from '@modules/system'; +import { SystemEntity } from '@modules/system/entity'; +import { UserService } from '@modules/user'; import { UserLoginMigrationNotActiveLoggableException } from '@modules/user-import/loggable/user-login-migration-not-active.loggable-exception'; import { UserLoginMigrationService, UserMigrationService } from '@modules/user-login-migration'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { MatchCreatorScope, SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, UserRepo } from '@shared/repo'; import { federalStateFactory, importUserFactory, legacySchoolDoFactory, schoolEntityFactory, setupEntities, + systemEntityFactory, + systemFactory, userDoFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; -import { systemEntityFactory } from '@shared/testing/factory/systemEntityFactory'; import { Logger } from '@src/core/logger'; -import { UserService } from '@modules/user'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; -import { - SchoolNotMigratedLoggableException, - UserAlreadyMigratedLoggable, - UserMigrationCanceledLoggable, -} from '../loggable'; - +import { SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable } from '../loggable'; import { UserImportService } from '../service'; +import { UserImportConfig } from '../user-import-config'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -49,7 +48,7 @@ describe('[ImportUserModule]', () => { let accountService: DeepMocked; let importUserRepo: DeepMocked; let schoolService: DeepMocked; - let systemRepo: DeepMocked; + let systemService: DeepMocked; let userRepo: DeepMocked; let userService: DeepMocked; let authorizationService: DeepMocked; @@ -58,7 +57,12 @@ describe('[ImportUserModule]', () => { let userMigrationService: DeepMocked; let logger: DeepMocked; - let userImportFeatures: IUserImportFeatures; + const config: UserImportConfig = { + FEATURE_USER_MIGRATION_ENABLED: true, + FEATURE_USER_MIGRATION_SYSTEM_ID: new ObjectId().toHexString(), + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: false, + IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: 80000, + }; beforeAll(async () => { await setupEntities(); @@ -66,6 +70,12 @@ describe('[ImportUserModule]', () => { module = await Test.createTestingModule({ providers: [ UserImportUc, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: keyof UserImportConfig) => config[key]), + }, + }, { provide: AccountService, useValue: createMock(), @@ -79,8 +89,8 @@ describe('[ImportUserModule]', () => { useValue: createMock(), }, { - provide: LegacySystemRepo, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: UserRepo, @@ -102,10 +112,6 @@ describe('[ImportUserModule]', () => { provide: UserLoginMigrationService, useValue: createMock(), }, - { - provide: UserImportFeatures, - useValue: {}, - }, { provide: UserMigrationService, useValue: createMock(), @@ -121,23 +127,20 @@ describe('[ImportUserModule]', () => { accountService = module.get(AccountService); importUserRepo = module.get(ImportUserRepo); schoolService = module.get(LegacySchoolService); - systemRepo = module.get(LegacySystemRepo); + systemService = module.get(SystemService); userRepo = module.get(UserRepo); userService = module.get(UserService); authorizationService = module.get(AuthorizationService); userImportService = module.get(UserImportService); - userImportFeatures = module.get(UserImportFeatures); userLoginMigrationService = module.get(UserLoginMigrationService); userMigrationService = module.get(UserMigrationService); logger = module.get(Logger); }); beforeEach(() => { - Object.assign(userImportFeatures, { - userMigrationEnabled: true, - userMigrationSystemId: new ObjectId().toHexString(), - useWithUserLoginMigration: false, - }); + config.FEATURE_USER_MIGRATION_ENABLED = true; + config.FEATURE_USER_MIGRATION_SYSTEM_ID = new ObjectId().toHexString(); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = false; }); afterAll(async () => { @@ -660,7 +663,7 @@ describe('[ImportUserModule]', () => { userService.findByExternalId.mockResolvedValueOnce(null); schoolService.getSchoolById.mockResolvedValueOnce(school); importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; return { user, @@ -728,7 +731,7 @@ describe('[ImportUserModule]', () => { userDoFactory.buildWithId({ id: user.id, externalId: user.externalId }) ); importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; return { user, @@ -800,6 +803,7 @@ describe('[ImportUserModule]', () => { describe('[startSchoolInUserMigration]', () => { let system: SystemEntity; + let systemDo: System; let school: SchoolEntity; let currentUser: User; let userRepoByIdSpy: jest.SpyInstance; @@ -812,6 +816,7 @@ describe('[ImportUserModule]', () => { beforeEach(() => { system = systemEntityFactory.buildWithId({ ldapConfig: {} }); + systemDo = systemFactory.build({ id: system.id, ldapConfig: {} }); school = schoolEntityFactory.buildWithId(); school.officialSchoolNumber = 'foo'; currentUser = userFactory.buildWithId({ school }); @@ -819,8 +824,8 @@ describe('[ImportUserModule]', () => { permissionServiceSpy = authorizationService.checkAllPermissions.mockReturnValue(); schoolServiceSaveSpy = schoolService.save.mockReturnValueOnce(Promise.resolve(createMockSchoolDo(school))); schoolServiceSpy = schoolService.getSchoolById.mockResolvedValue(createMockSchoolDo(school)); - systemRepoSpy = systemRepo.findById.mockReturnValueOnce(Promise.resolve(system)); - userImportFeatures.userMigrationSystemId = system.id; + systemRepoSpy = systemService.findById.mockReturnValueOnce(Promise.resolve(systemDo)); + config.FEATURE_USER_MIGRATION_SYSTEM_ID = system.id; dateSpy = jest.spyOn(global, 'Date').mockReturnValue(currentDate as unknown as string); }); @@ -851,7 +856,7 @@ describe('[ImportUserModule]', () => { schoolServiceSaveSpy = schoolService.save.mockImplementation((schoolDo: LegacySchoolDo) => Promise.resolve(schoolDo) ); - userImportService.getMigrationSystem.mockResolvedValueOnce(system); + userImportService.getMigrationSystem.mockResolvedValueOnce(systemDo); await uc.startSchoolInUserMigration(currentUser.id); @@ -932,7 +937,7 @@ describe('[ImportUserModule]', () => { targetSystemId, }); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); @@ -982,7 +987,7 @@ describe('[ImportUserModule]', () => { closedAt: new Date(), }); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); @@ -1012,7 +1017,7 @@ describe('[ImportUserModule]', () => { systems: [targetSystemId], }); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); @@ -1045,7 +1050,7 @@ describe('[ImportUserModule]', () => { targetSystemId, }); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); @@ -1142,7 +1147,7 @@ describe('[ImportUserModule]', () => { userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; return { user, @@ -1171,7 +1176,7 @@ describe('[ImportUserModule]', () => { userRepo.findById.mockResolvedValueOnce(user); schoolService.getSchoolById.mockResolvedValueOnce(school); - userImportFeatures.useWithUserLoginMigration = true; + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; return { user, @@ -1195,35 +1200,12 @@ describe('[ImportUserModule]', () => { expect(userImportService.checkFeatureEnabled).toHaveBeenCalled(); }); - it('should delete import users for school', async () => { - const { user } = setup(); - - await uc.cancelMigration(user.id); - - expect(importUserRepo.deleteImportUsersBySchool).toHaveBeenCalledWith(user.school); - }); - - it('should save school with reset migration flags', async () => { + it('should call reset migration', async () => { const { user, school } = setup(); await uc.cancelMigration(user.id); - expect(schoolService.save).toHaveBeenCalledWith( - { - ...school, - inUserMigration: undefined, - inMaintenanceSince: undefined, - }, - true - ); - }); - - it('should log canceled migration', async () => { - const { user } = setup(); - - await uc.cancelMigration(user.id); - - expect(logger.notice).toHaveBeenCalledWith(new UserMigrationCanceledLoggable(expect.any(LegacySchoolDo))); + expect(userImportService.resetMigrationForUsersSchool).toHaveBeenCalledWith(user, 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 06ef3403eb0..6a8547fc4a3 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 @@ -1,19 +1,20 @@ import { Account, AccountSave, AccountService } from '@modules/account'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; +import { System, SystemService } from '@modules/system'; +import { UserService } from '@modules/user'; import { UserLoginMigrationNotActiveLoggableException } from '@modules/user-import/loggable/user-login-migration-not-active.loggable-exception'; import { UserLoginMigrationService, UserMigrationService } from '@modules/user-login-migration'; -import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, MatchCreator, User } from '@shared/domain/entity'; import { IFindOptions, Permission } from '@shared/domain/interface'; import { Counted, EntityId, IImportUserScope, MatchCreatorScope, NameMatch } from '@shared/domain/types'; -import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; -import { UserService } from '@modules/user'; -import { IUserImportFeatures, UserImportFeatures } from '../config'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, @@ -22,10 +23,10 @@ import { SchoolInUserMigrationStartLoggable, SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable, - UserMigrationCanceledLoggable, } from '../loggable'; import { UserImportService } from '../service'; +import { UserImportConfig } from '../user-import-config'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -40,16 +41,16 @@ export type UserImportPermissions = @Injectable() export class UserImportUc { constructor( + private readonly configService: ConfigService, private readonly accountService: AccountService, private readonly importUserRepo: ImportUserRepo, private readonly authorizationService: AuthorizationService, private readonly schoolService: LegacySchoolService, - private readonly systemRepo: LegacySystemRepo, + private readonly systemService: SystemService, private readonly userRepo: UserRepo, private readonly userService: UserService, private readonly logger: Logger, private readonly userImportService: UserImportService, - @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, private readonly userLoginMigrationService: UserLoginMigrationService, private readonly userMigrationService: UserMigrationService ) { @@ -238,9 +239,11 @@ export class UserImportUc { } public async startSchoolInUserMigration(currentUserId: EntityId, useCentralLdap = true): Promise { - const { useWithUserLoginMigration } = this.userImportFeatures; + const FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = this.configService.get( + 'FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION' + ); - if (useWithUserLoginMigration) { + if (FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION) { useCentralLdap = false; } @@ -248,11 +251,11 @@ export class UserImportUc { const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); this.userImportService.checkFeatureEnabled(school); - if (useCentralLdap || useWithUserLoginMigration) { + if (useCentralLdap || FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION) { this.checkSchoolNumber(school); } this.checkSchoolNotInMigration(school); - if (useWithUserLoginMigration) { + if (FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION) { await this.checkSchoolMigrated(currentUser.school.id, school); await this.checkMigrationActive(currentUser.school.id); } else { @@ -261,7 +264,7 @@ export class UserImportUc { this.logger.notice(new SchoolInUserMigrationStartLoggable(currentUserId, school.name, useCentralLdap)); - if (!useWithUserLoginMigration) { + if (!FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION) { school.externalId = school.officialSchoolNumber; } @@ -269,7 +272,7 @@ export class UserImportUc { school.inMaintenanceSince = new Date(); if (useCentralLdap) { - const migrationSystem: SystemEntity = await this.userImportService.getMigrationSystem(); + const migrationSystem: System = await this.userImportService.getMigrationSystem(); if (school.systems && !school.systems.includes(migrationSystem.id)) { school.systems.push(migrationSystem.id); @@ -316,7 +319,9 @@ export class UserImportUc { school.inMaintenanceSince = undefined; - const isMigrationRestartable: boolean = this.userImportFeatures.useWithUserLoginMigration; + const isMigrationRestartable: boolean = this.configService.get( + 'FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION' + ); if (isMigrationRestartable) { school.inUserMigration = undefined; } @@ -332,14 +337,7 @@ export class UserImportUc { this.userImportService.checkFeatureEnabled(school); - await this.importUserRepo.deleteImportUsersBySchool(currentUser.school); - - school.inUserMigration = undefined; - school.inMaintenanceSince = undefined; - - await this.schoolService.save(school, true); - - this.logger.notice(new UserMigrationCanceledLoggable(school)); + await this.userImportService.resetMigrationForUsersSchool(currentUser, school); } private async getCurrentUser(currentUserId: EntityId, permission: UserImportPermissions): Promise { @@ -350,9 +348,11 @@ export class UserImportUc { } private async updateUserAndAccount(importUser: ImportUser, school: LegacySchoolDo): Promise { - const { useWithUserLoginMigration } = this.userImportFeatures; + const FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = this.configService.get( + 'FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION' + ); - if (useWithUserLoginMigration) { + if (FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION) { await this.updateUserAndAccountWithUserLoginMigration(importUser); } else { await this.updateUserAndAccountWithLdap(importUser, school); @@ -415,9 +415,9 @@ export class UserImportUc { for (const systemId of school.systems) { // very unusual to have more than 1 system // eslint-disable-next-line no-await-in-loop - const system: SystemEntity = await this.systemRepo.findById(systemId); + const system: System | null = await this.systemService.findById(systemId); - if (system.ldapConfig) { + if (system?.ldapConfig) { throw new LdapAlreadyPersistedException(); } } diff --git a/apps/server/src/modules/user-import/user-import-config.module.ts b/apps/server/src/modules/user-import/user-import-config.module.ts deleted file mode 100644 index 34ba1e5f994..00000000000 --- a/apps/server/src/modules/user-import/user-import-config.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UserImportConfiguration, UserImportFeatures } from './config'; - -@Module({ - providers: [ - { - provide: UserImportFeatures, - useValue: UserImportConfiguration.userImportFeatures, - }, - ], - exports: [UserImportFeatures], -}) -export class UserImportConfigModule {} diff --git a/apps/server/src/modules/user-import/user-import-config.ts b/apps/server/src/modules/user-import/user-import-config.ts index a7145a56d1b..ce005c20ce5 100644 --- a/apps/server/src/modules/user-import/user-import-config.ts +++ b/apps/server/src/modules/user-import/user-import-config.ts @@ -1,3 +1,6 @@ export interface UserImportConfig { + FEATURE_USER_MIGRATION_ENABLED: boolean; + FEATURE_USER_MIGRATION_SYSTEM_ID: string; + FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION: boolean; IMPORTUSER_SAVE_ALL_MATCHES_REQUEST_TIMEOUT_MS: number; } 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 7caed266954..b69b80b9fd1 100644 --- a/apps/server/src/modules/user-import/user-import.module.ts +++ b/apps/server/src/modules/user-import/user-import.module.ts @@ -3,16 +3,16 @@ import { AccountModule } from '@modules/account'; import { AuthorizationModule } from '@modules/authorization'; import { LegacySchoolModule } from '@modules/legacy-school'; import { OauthModule } from '@modules/oauth'; +import { SystemModule } from '@modules/system'; import { UserModule } from '@modules/user'; import { UserLoginMigrationModule } from '@modules/user-login-migration'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySchoolRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { ImportUserController } from './controller/import-user.controller'; import { SchulconnexFetchImportUsersService, UserImportService } from './service'; import { UserImportFetchUc, UserImportUc } from './uc'; -import { UserImportConfigModule } from './user-import-config.module'; @Module({ imports: [ @@ -20,12 +20,12 @@ import { UserImportConfigModule } from './user-import-config.module'; AccountModule, LegacySchoolModule, AuthorizationModule, - UserImportConfigModule, HttpModule, UserModule, OauthModule, SchulconnexClientModule.registerAsync(), UserLoginMigrationModule, + SystemModule, ], controllers: [ImportUserController], providers: [ @@ -33,12 +33,11 @@ import { UserImportConfigModule } from './user-import-config.module'; UserImportFetchUc, ImportUserRepo, LegacySchoolRepo, - LegacySystemRepo, UserRepo, UserImportService, SchulconnexFetchImportUsersService, ], - exports: [], + exports: [UserImportService], }) /** * Module to provide user migration, diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts index 1e9e30ecc6c..940d596d9fa 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts @@ -1,9 +1,9 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { ServerTestModule } from '@modules/server'; +import { SystemEntity } from '@modules/system/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { cleanupCollections, @@ -13,6 +13,7 @@ import { UserAndAccountTestFactory, userLoginMigrationFactory, } from '@shared/testing'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { Response } from 'supertest'; describe('UserLoginMigrationRollbackController (API)', () => { 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 258badeb35e..cb5be880941 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 @@ -4,14 +4,16 @@ import { SchulconnexLizenzInfoResponse } from '@infra/schulconnex-client/respons import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server'; +import { type SystemEntity } from '@modules/system/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ImportUser, SchoolEntity, User } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { cleanupCollections, + importUserFactory, JwtTestFactory, schoolEntityFactory, systemEntityFactory, @@ -1342,5 +1344,65 @@ describe('UserLoginMigrationController (API)', () => { expect(response.body).toEqual({}); }); }); + + describe('when the migration wizard is also running', () => { + const setup = async () => { + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + inUserMigration: true, + inMaintenanceSince: new Date(2024, 1, 4), + }); + const importUser = importUserFactory.build({ school }); + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + }); + + const migratedUser: User = userFactory.buildWithId({ + lastLoginSystemChange: new Date(2023, 1, 5), + }); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + adminAccount, + adminUser, + userLoginMigration, + migratedUser, + importUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + adminUser, + }; + }; + + it('should close migration wizard', async () => { + const { loggedInClient, adminUser } = await setup(); + + await loggedInClient.post('/close'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [entities, count] = await em.findAndCount(ImportUser, {}); + expect(count).toEqual(0); + + const school = await em.findOneOrFail(SchoolEntity, { id: adminUser.school.id }); + expect(school.inUserMigration).toBe(undefined); + expect(school.inMaintenanceSince).toBe(undefined); + }); + }); }); }); diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts index 4ac3dc6f6dd..81019ee19a2 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts @@ -20,6 +20,7 @@ import { } from '../loggable'; import { UserLoginMigrationMapper } from '../mapper'; import { + CloseMigrationWizardUc, CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, StartUserLoginMigrationUc, @@ -45,7 +46,8 @@ export class UserLoginMigrationController { private readonly startUserLoginMigrationUc: StartUserLoginMigrationUc, private readonly restartUserLoginMigrationUc: RestartUserLoginMigrationUc, private readonly toggleUserLoginMigrationUc: ToggleUserLoginMigrationUc, - private readonly closeUserLoginMigrationUc: CloseUserLoginMigrationUc + private readonly closeUserLoginMigrationUc: CloseUserLoginMigrationUc, + private readonly closeMigrationWizardUc: CloseMigrationWizardUc ) {} @Get() @@ -194,7 +196,7 @@ export class UserLoginMigrationController { description: 'User login migration does not exist', type: UserLoginMigrationNotFoundLoggableException, }) - @ApiOkResponse({ description: 'User login migration closed', type: UserLoginMigrationResponse }) + @ApiOkResponse({ description: 'User login migration and migration wizard closed', type: UserLoginMigrationResponse }) @ApiUnauthorizedResponse() @ApiForbiddenResponse() @ApiNoContentResponse({ description: 'User login migration was reverted' }) @@ -205,10 +207,13 @@ export class UserLoginMigrationController { ); if (userLoginMigration) { + await this.closeMigrationWizardUc.closeMigrationWizardWhenActive(currentUser.userId); + const migrationResponse: UserLoginMigrationResponse = UserLoginMigrationMapper.mapUserLoginMigrationDoToResponse(userLoginMigration); return migrationResponse; } + return undefined; } 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 ee4a7cff300..cb3ecb70348 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,14 +2,13 @@ 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 { LegacySystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service'; +import { System, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { UserLoginMigrationRepo } from '@shared/repo'; -import { legacySchoolDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { legacySchoolDoFactory, systemFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { IdenticalUserLoginMigrationSystemLoggableException, MoinSchuleSystemNotFoundLoggableException, @@ -24,7 +23,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'); @@ -48,8 +47,8 @@ describe(UserLoginMigrationService.name, () => { useValue: createMock(), }, { - provide: LegacySystemService, - useValue: createMock(), + provide: SystemService, + useValue: createMock(), }, { provide: UserLoginMigrationRepo, @@ -61,7 +60,7 @@ describe(UserLoginMigrationService.name, () => { service = module.get(UserLoginMigrationService); userService = module.get(UserService); schoolService = module.get(LegacySchoolService); - systemService = module.get(LegacySystemService); + systemService = module.get(SystemService); userLoginMigrationRepo = module.get(UserLoginMigrationRepo); }); @@ -165,9 +164,8 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ + const system: System = systemFactory.withOauthConfig().build({ id: targetSystemId, - type: 'oauth2', alias: 'SANIS', }); @@ -180,7 +178,7 @@ describe(UserLoginMigrationService.name, () => { }); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); + systemService.find.mockResolvedValue([system]); userLoginMigrationRepo.save.mockResolvedValue(userLoginMigrationDO); return { @@ -218,9 +216,8 @@ describe(UserLoginMigrationService.name, () => { const setup = () => { const sourceSystemId: EntityId = new ObjectId().toHexString(); const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ + const system: System = systemFactory.withOauthConfig().build({ id: targetSystemId, - type: 'oauth2', alias: 'SANIS', }); @@ -228,7 +225,7 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [sourceSystemId] }, schoolId); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); + systemService.find.mockResolvedValue([system]); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { @@ -261,14 +258,13 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ + const system: System = systemFactory.withOauthConfig().build({ id: targetSystemId, - type: 'oauth2', alias: 'SANIS', }); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); + systemService.find.mockResolvedValue([system]); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { @@ -298,14 +294,13 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: undefined }, schoolId); const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ + const system: System = systemFactory.withOauthConfig().build({ id: targetSystemId, - type: 'oauth2', alias: 'SANIS', }); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); + systemService.find.mockResolvedValue([system]); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { @@ -332,7 +327,7 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([]); + systemService.find.mockResolvedValue([]); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { @@ -352,9 +347,8 @@ describe(UserLoginMigrationService.name, () => { describe('when creating a new migration but the SANIS system and schools login system are the same', () => { const setup = () => { const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ + const system: System = systemFactory.withOauthConfig().build({ id: targetSystemId, - type: 'oauth2', alias: 'SANIS', }); @@ -362,7 +356,7 @@ describe(UserLoginMigrationService.name, () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [targetSystemId] }, schoolId); schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); + systemService.find.mockResolvedValue([system]); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { 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 44876628e7a..2ab7f749934 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,16 +1,17 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { LegacySchoolService } from '@modules/legacy-school'; -import { LegacySystemService, SystemDto } from '@modules/system'; +import { System, SystemService } from '@modules/system'; +import { SystemType } from '@modules/system/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { EntityId, SchoolFeature, SystemTypeEnum } from '@shared/domain/types'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; import { UserLoginMigrationRepo } from '@shared/repo'; import { - UserLoginMigrationAlreadyClosedLoggableException, - UserLoginMigrationGracePeriodExpiredLoggableException, IdenticalUserLoginMigrationSystemLoggableException, MoinSchuleSystemNotFoundLoggableException, + UserLoginMigrationAlreadyClosedLoggableException, + UserLoginMigrationGracePeriodExpiredLoggableException, } from '../loggable'; @Injectable() @@ -19,7 +20,7 @@ export class UserLoginMigrationService { private readonly userService: UserService, private readonly userLoginMigrationRepo: UserLoginMigrationRepo, private readonly schoolService: LegacySchoolService, - private readonly systemService: LegacySystemService + private readonly systemService: SystemService ) {} public async startMigration(schoolId: string): Promise { @@ -111,18 +112,18 @@ export class UserLoginMigrationService { } private async createNewMigration(school: LegacySchoolDo): Promise { - const oauthSystems: SystemDto[] = await this.systemService.findByType(SystemTypeEnum.OAUTH); - const moinSchuleSystem: SystemDto | undefined = oauthSystems.find((system: SystemDto) => system.alias === 'SANIS'); + const oauthSystems: System[] = await this.systemService.find({ types: [SystemType.OAUTH] }); + const moinSchuleSystem: System | undefined = oauthSystems.find((system: System) => system.alias === 'SANIS'); if (!moinSchuleSystem) { throw new MoinSchuleSystemNotFoundLoggableException(); - } else if (school.systems?.includes(moinSchuleSystem.id as string)) { + } else if (school.systems?.includes(moinSchuleSystem.id)) { throw new IdenticalUserLoginMigrationSystemLoggableException(school.id, moinSchuleSystem.id); } const userLoginMigrationDO: UserLoginMigrationDO = new UserLoginMigrationDO({ schoolId: school.id as string, - targetSystemId: moinSchuleSystem.id as string, + targetSystemId: moinSchuleSystem.id, sourceSystemId: school.systems?.[0], startedAt: new Date(), }); diff --git a/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.spec.ts new file mode 100644 index 00000000000..b8e7b9f589c --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.spec.ts @@ -0,0 +1,118 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { UserImportService } from '@modules/user-import'; +import { Test, TestingModule } from '@nestjs/testing'; +import { legacySchoolDoFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { CloseMigrationWizardUc } from './close-migration-wizard.uc'; + +describe(CloseMigrationWizardUc.name, () => { + let module: TestingModule; + let uc: CloseMigrationWizardUc; + + let schoolService: DeepMocked; + let userImportService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + CloseMigrationWizardUc, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + { + provide: UserImportService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(CloseMigrationWizardUc); + schoolService = module.get(LegacySchoolService); + userImportService = module.get(UserImportService); + authorizationService = module.get(AuthorizationService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('closeMigrationWizardWhenActive', () => { + describe('when school is in user migration', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const user = userFactory.buildWithId({ school: schoolEntityFactory.build({ _id: schoolId }) }); + const school = legacySchoolDoFactory.build({ inUserMigration: true }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + + return { + user, + school, + }; + }; + + it('should check users permissions', async () => { + const { user } = setup(); + + await uc.closeMigrationWizardWhenActive(user.id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, ['IMPORT_USER_MIGRATE']); + }); + + it('should reset migration wizard for users school', async () => { + const { user, school } = setup(); + + await uc.closeMigrationWizardWhenActive(user.id); + + expect(userImportService.resetMigrationForUsersSchool).toHaveBeenCalledWith(user, school); + }); + }); + + describe('when school is not in user migration', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const user = userFactory.buildWithId({ school: schoolEntityFactory.build({ _id: schoolId }) }); + const school = legacySchoolDoFactory.build({ inUserMigration: false }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + + return { + user, + school, + }; + }; + + it('should not check users permissions', async () => { + const { user } = setup(); + + await uc.closeMigrationWizardWhenActive(user.id); + + expect(authorizationService.checkAllPermissions).not.toHaveBeenCalled(); + }); + + it('should not reset migration wizard for users school', async () => { + const { user } = setup(); + + await uc.closeMigrationWizardWhenActive(user.id); + + expect(userImportService.resetMigrationForUsersSchool).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.ts b/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.ts new file mode 100644 index 00000000000..4071b5a5400 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/close-migration-wizard.uc.ts @@ -0,0 +1,28 @@ +import { AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { UserImportService } from '@modules/user-import'; +import { Injectable } from '@nestjs/common'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; + +@Injectable() +export class CloseMigrationWizardUc { + constructor( + private readonly schoolService: LegacySchoolService, + private readonly userImportService: UserImportService, + private readonly authorizationService: AuthorizationService + ) {} + + public async closeMigrationWizardWhenActive(userId: EntityId): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: LegacySchoolDo = await this.schoolService.getSchoolById(user.school.id); + + if (school.inUserMigration) { + this.authorizationService.checkAllPermissions(user, [Permission.IMPORT_USER_MIGRATE]); + + await this.userImportService.resetMigrationForUsersSchool(user, school); + } + } +} diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts index 5228e644733..caabd374df0 100644 --- a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts @@ -16,7 +16,7 @@ export class CloseUserLoginMigrationUc { private readonly authorizationService: AuthorizationService ) {} - async closeMigration(userId: EntityId, schoolId: EntityId): Promise { + public async closeMigration(userId: EntityId, schoolId: EntityId): Promise { const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( schoolId ); diff --git a/apps/server/src/modules/user-login-migration/uc/index.ts b/apps/server/src/modules/user-login-migration/uc/index.ts index da6c995ded5..5a4d6eb5f7d 100644 --- a/apps/server/src/modules/user-login-migration/uc/index.ts +++ b/apps/server/src/modules/user-login-migration/uc/index.ts @@ -5,3 +5,4 @@ export * from './toggle-user-login-migration.uc'; export * from './restart-user-login-migration.uc'; export * from './close-user-login-migration.uc'; export * from './user-login-migration-rollback.uc'; +export { CloseMigrationWizardUc } from './close-migration-wizard.uc'; 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 3fce00aac9d..0da0459e6a0 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 @@ -11,11 +11,12 @@ import { ProvisioningService, ProvisioningSystemDto, } from '@modules/provisioning'; +import { SystemEntity } from '@modules/system/entity'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, Page, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { SystemEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { diff --git a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts index 21471537a76..cf2151e31eb 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts @@ -3,11 +3,13 @@ import { AuthorizationModule } from '@modules/authorization'; import { LegacySchoolModule } from '@modules/legacy-school'; import { OauthModule } from '@modules/oauth'; import { ProvisioningModule } from '@modules/provisioning'; +import { ImportUserModule } from '@modules/user-import'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { UserLoginMigrationRollbackController } from './controller/user-login-migration-rollback.controller'; import { UserLoginMigrationController } from './controller/user-login-migration.controller'; import { + CloseMigrationWizardUc, CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, StartUserLoginMigrationUc, @@ -26,6 +28,7 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; AuthorizationModule, LoggerModule, LegacySchoolModule, + ImportUserModule, ], providers: [ UserLoginMigrationUc, @@ -34,6 +37,7 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; ToggleUserLoginMigrationUc, CloseUserLoginMigrationUc, UserLoginMigrationRollbackUc, + CloseMigrationWizardUc, ], controllers: [UserLoginMigrationController, UserLoginMigrationRollbackController], }) 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 98bd755ef61..eee9f447616 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -946,119 +946,4 @@ describe('UserService', () => { }); }); }); - - describe('isEmailUniqueForExternal', () => { - describe('when email does not exist', () => { - const setup = () => { - const email = 'email'; - - userDORepo.findByEmail.mockResolvedValue([]); - - return { - email, - }; - }; - - it('should return true', async () => { - const { email } = setup(); - - const result: boolean = await service.isEmailUniqueForExternal(email, undefined); - - expect(result).toBe(true); - }); - }); - - describe('when an existing user is found', () => { - describe('when existing user is the external user', () => { - const setup = () => { - const email = 'email'; - const externalId = 'externalId'; - const existingUser: UserDO = userDoFactory.build({ email, externalId }); - - userDORepo.findByEmail.mockResolvedValue([existingUser]); - - return { - email, - externalId, - }; - }; - - it('should return true', async () => { - const { email, externalId } = setup(); - - const result: boolean = await service.isEmailUniqueForExternal(email, externalId); - - expect(result).toBe(true); - }); - }); - - describe('when existing user is not the external user', () => { - const setup = () => { - const email = 'email'; - const externalId = 'externalId'; - const otherUserWithSameEmail: UserDO = userDoFactory.build({ email }); - - userDORepo.findByEmail.mockResolvedValue([otherUserWithSameEmail]); - - return { - email, - externalId, - }; - }; - - it('should return false', async () => { - const { email, externalId } = setup(); - - const result: boolean = await service.isEmailUniqueForExternal(email, externalId); - - expect(result).toBe(false); - }); - }); - - describe('when existing user is not the external user and external user is not already provisioned.', () => { - const setup = () => { - const email = 'email'; - const otherUserWithSameEmail: UserDO = userDoFactory.build({ email }); - - userDORepo.findByEmail.mockResolvedValue([otherUserWithSameEmail]); - - return { - email, - }; - }; - - it('should return false', async () => { - const { email } = setup(); - - const result: boolean = await service.isEmailUniqueForExternal(email, undefined); - - expect(result).toBe(false); - }); - }); - }); - - describe('when multiple users are found', () => { - const setup = () => { - const email = 'email'; - const externalId = 'externalId'; - const existingUser: UserDO = userDoFactory.build({ email, externalId }); - const otherUserWithSameEmail: UserDO = userDoFactory.build({ email }); - - userDORepo.findByEmail.mockResolvedValue([existingUser, otherUserWithSameEmail]); - - return { - email, - externalId, - }; - }; - - it('should return false', async () => { - const { email, externalId } = setup(); - - const result: boolean = await service.isEmailUniqueForExternal(email, externalId); - - expect(result).toBe(false); - }); - }); - }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index f474bdfbefc..33394ff30dc 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -283,18 +283,4 @@ export class UserService implements DeletionService, IEventHandler { - const foundUsers: UserDO[] = await this.findByEmail(email); - if (!externalId && foundUsers.length) { - return false; - } - - const unmatchedUsers: UserDO[] = foundUsers.filter((user: UserDO) => user.externalId !== externalId); - if (unmatchedUsers.length) { - return false; - } - - return true; - } } diff --git a/apps/server/src/modules/user/uc/admin-api-user.uc.ts b/apps/server/src/modules/user/uc/admin-api-user.uc.ts index adf4242a0f5..bf58d4e9493 100644 --- a/apps/server/src/modules/user/uc/admin-api-user.uc.ts +++ b/apps/server/src/modules/user/uc/admin-api-user.uc.ts @@ -34,6 +34,7 @@ export class AdminApiUserUc { username: props.email, userId: user.id, password: initialPassword, + activated: true, } as AccountSave); return { userId: user.id, diff --git a/apps/server/src/modules/video-conference/bbb/bbb-config.ts b/apps/server/src/modules/video-conference/bbb/bbb-config.ts new file mode 100644 index 00000000000..61488050134 --- /dev/null +++ b/apps/server/src/modules/video-conference/bbb/bbb-config.ts @@ -0,0 +1,5 @@ +export interface BbbConfig { + VIDEOCONFERENCE_HOST: string; + VIDEOCONFERENCE_SALT: string; + VIDEOCONFERENCE_DEFAULT_PRESENTATION: string; +} diff --git a/apps/server/src/modules/video-conference/bbb/bbb-settings.interface.ts b/apps/server/src/modules/video-conference/bbb/bbb-settings.interface.ts deleted file mode 100644 index c250bebb929..00000000000 --- a/apps/server/src/modules/video-conference/bbb/bbb-settings.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const BbbSettings = Symbol('BbbSettings'); - -export interface IBbbSettings { - host: string; - salt: string; - presentationUrl: string; -} diff --git a/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts b/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts index 1731d10ff8e..258f06f10c6 100644 --- a/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts +++ b/apps/server/src/modules/video-conference/bbb/bbb.service.spec.ts @@ -1,15 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConverterUtil } from '@shared/common'; import { axiosResponseFactory } from '@shared/testing'; import { ErrorUtils } from '@src/core/error/utils'; import { AxiosResponse } from 'axios'; import crypto, { Hash } from 'crypto'; import { of } from 'rxjs'; import { URLSearchParams } from 'url'; -import { BbbSettings, IBbbSettings } from './bbb-settings.interface'; +import { VideoConferenceConfig } from '../video-conference-config'; +import { BbbConfig } from './bbb-config'; import { BBBService } from './bbb.service'; import { BBBBaseMeetingConfig, BBBCreateConfig, BBBJoinConfig, BBBRole, GuestPolicy } from './request'; import { BBBBaseResponse, BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBStatus } from './response'; @@ -106,43 +107,43 @@ class BBBServiceTest extends BBBService { } } -describe('BBB Service', () => { +describe(BBBService.name, () => { let module: TestingModule; let service: BBBServiceTest; let httpService: DeepMocked; - let converterUtil: DeepMocked; + let configService: DeepMocked>; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ BBBServiceTest, { - provide: BbbSettings, - useValue: createMock({ - host: 'https://bbb.de', - salt: 'salt12345', - presentationUrl: '', - }), + provide: ConfigService, + useValue: createMock>(), }, { provide: HttpService, useValue: createMock(), }, - { - provide: ConverterUtil, - useValue: createMock(), - }, ], }).compile(); service = module.get(BBBServiceTest); httpService = module.get(HttpService); - converterUtil = module.get(ConverterUtil); + configService = module.get(ConfigService); }); afterAll(async () => { await module.close(); }); + beforeEach(() => { + configService.get.mockReturnValue('https://mocked'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + describe('create', () => { describe('when valid parameter passed and the BBB response well', () => { const setup = () => { @@ -152,20 +153,20 @@ describe('BBB Service', () => { const param = createBBBCreateConfig(); - httpService.post.mockReturnValue(of(bbbCreateResponse)); - converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); + httpService.post.mockReturnValueOnce(of(bbbCreateResponse)); + const spy = jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbCreateResponse.data); - return { param, bbbCreateResponse }; + return { param, bbbCreateResponse, spy }; }; it('should return a response with returncode success', async () => { - const { bbbCreateResponse, param } = setup(); + const { bbbCreateResponse, param, spy } = setup(); const result = await service.create(param); expect(result).toBeDefined(); expect(httpService.post).toHaveBeenCalledTimes(1); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbCreateResponse.data); + expect(spy).toHaveBeenCalledWith(bbbCreateResponse.data); }); }); @@ -178,8 +179,8 @@ describe('BBB Service', () => { const param = createBBBCreateConfig(); - httpService.post.mockReturnValue(of(bbbCreateResponse)); - converterUtil.xml2object.mockReturnValue(bbbCreateResponse.data); + httpService.post.mockReturnValueOnce(of(bbbCreateResponse)); + jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbCreateResponse.data); const error = new InternalServerErrorException( `${bbbCreateResponse.data.response.messageKey}, ${bbbCreateResponse.data.response.message}` @@ -198,19 +199,6 @@ describe('BBB Service', () => { await expect(service.create(param)).rejects.toThrowError(expectedError); }); }); - - it('should return a xml configuration with provided presentation url', () => { - // Arrange - const presentationUrl = 'https://s3.hidrive.strato.com/cloud-instances/bbb/presentation.pdf'; - - // Act - const result = service.getBbbRequestConfig(presentationUrl); - - // Assert - expect(result).toBe( - "" - ); - }); }); describe('end', () => { @@ -221,20 +209,20 @@ describe('BBB Service', () => { ); const bbbBaseMeetingConfig: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; - httpService.get.mockReturnValue(of(bbbBaseResponse)); - converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); + httpService.get.mockReturnValueOnce(of(bbbBaseResponse)); + const spy = jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbBaseResponse.data); - return { bbbBaseResponse, bbbBaseMeetingConfig }; + return { bbbBaseResponse, bbbBaseMeetingConfig, spy }; }; it('should return a response with returncode success', async () => { - const { bbbBaseResponse, bbbBaseMeetingConfig } = setup(); + const { bbbBaseResponse, bbbBaseMeetingConfig, spy } = setup(); const result = await service.end(bbbBaseMeetingConfig); expect(result).toBeDefined(); expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbBaseResponse.data); + expect(spy).toHaveBeenCalledWith(bbbBaseResponse.data); }); }); @@ -246,8 +234,8 @@ describe('BBB Service', () => { bbbBaseResponse.data.response.returncode = BBBStatus.ERROR; const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; - httpService.get.mockReturnValue(of(bbbBaseResponse)); - converterUtil.xml2object.mockReturnValue(bbbBaseResponse.data); + httpService.get.mockReturnValueOnce(of(bbbBaseResponse)); + jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbBaseResponse.data); const error = new InternalServerErrorException( `${bbbBaseResponse.data.response.messageKey}, ${bbbBaseResponse.data.response.message}` @@ -276,19 +264,19 @@ describe('BBB Service', () => { ); const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + httpService.get.mockReturnValueOnce(of(bbbMeetingInfoResponse)); + const spy = jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbMeetingInfoResponse.data); - return { bbbMeetingInfoResponse, param }; + return { bbbMeetingInfoResponse, param, spy }; }; it('should return a response with returncode success', async () => { - const { bbbMeetingInfoResponse, param } = setup(); + const { bbbMeetingInfoResponse, param, spy } = setup(); const result = await service.getMeetingInfo(param); expect(result).toBeDefined(); expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); + expect(spy).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); }); }); @@ -300,8 +288,8 @@ describe('BBB Service', () => { bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; const param: BBBBaseMeetingConfig = { meetingID: 'meetingId' }; - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + httpService.get.mockReturnValueOnce(of(bbbMeetingInfoResponse)); + jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbMeetingInfoResponse.data); const error = new InternalServerErrorException( `${bbbMeetingInfoResponse.data.response.messageKey}: ${bbbMeetingInfoResponse.data.response.message}` @@ -330,20 +318,20 @@ describe('BBB Service', () => { ); const param: BBBJoinConfig = createBBBJoinConfig(); - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + httpService.get.mockReturnValueOnce(of(bbbMeetingInfoResponse)); + const spy = jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbMeetingInfoResponse.data); - return { param, bbbMeetingInfoResponse }; + return { param, bbbMeetingInfoResponse, spy }; }; it('should create a join link to a bbb meeting', async () => { - const { param, bbbMeetingInfoResponse } = setup(); + const { param, bbbMeetingInfoResponse, spy } = setup(); const url = await service.join(param); expect(url).toBeDefined(); expect(httpService.get).toBeCalled(); - expect(converterUtil.xml2object).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); + expect(spy).toHaveBeenCalledWith(bbbMeetingInfoResponse.data); }); }); @@ -355,8 +343,8 @@ describe('BBB Service', () => { bbbMeetingInfoResponse.data.response.returncode = BBBStatus.ERROR; const param: BBBJoinConfig = createBBBJoinConfig(); - httpService.get.mockReturnValue(of(bbbMeetingInfoResponse)); - converterUtil.xml2object.mockReturnValue(bbbMeetingInfoResponse.data); + httpService.get.mockReturnValueOnce(of(bbbMeetingInfoResponse)); + jest.spyOn(service, 'xml2object').mockReturnValueOnce(bbbMeetingInfoResponse.data); const error = new InternalServerErrorException( `${bbbMeetingInfoResponse.data.response.messageKey}: ${bbbMeetingInfoResponse.data.response.message}` @@ -377,13 +365,10 @@ describe('BBB Service', () => { }); it('toParams: should return params based on bbb configs', () => { - // Arrange - const createConfig: BBBCreateConfig = createBBBCreateConfig(); + const createConfig = createBBBCreateConfig(); - // Act - const params: URLSearchParams = service.superToParams(createConfig); + const params = service.superToParams(createConfig); - // Assert expect(params.get('name')).toEqual(createConfig.name); expect(params.get('meetingID')).toEqual(createConfig.meetingID); expect(params.get('logoutURL')).toEqual(createConfig.logoutURL); @@ -394,38 +379,41 @@ describe('BBB Service', () => { expect(params.get('allowModsToUnmuteUsers')).toEqual(String(createConfig.allowModsToUnmuteUsers)); }); - it('generateChecksum: should generate a checksum for queryParams', () => { - // Arrange + const setup = () => { const hashMock: Hash = { update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('encrypt 123'), + digest: jest.fn().mockReturnValueOnce('encrypt 123').mockReturnValueOnce('encrypt 123'), } as unknown as Hash; const createHashMock = jest.spyOn(crypto, 'createHash').mockImplementation((): Hash => hashMock); - const createConfig: BBBCreateConfig = createBBBCreateConfig(); + const createConfig = createBBBCreateConfig(); const callName = 'create'; - const urlSearchParams: URLSearchParams = service.superToParams(createConfig); - const queryString: string = urlSearchParams.toString(); + + return { + callName, + createConfig, + createHashMock, + }; + }; + + it('generateChecksum: should generate a checksum for queryParams', () => { + const { callName, createConfig, createHashMock } = setup(); + const urlSearchParams = service.superToParams(createConfig); + const queryString = urlSearchParams.toString(); const sha = crypto.createHash('sha1'); - const expectedChecksum: string = sha.update(callName + queryString + service.getSalt()).digest('hex'); + const expectedChecksum = sha.update(callName + queryString + service.getSalt()).digest('hex'); - // Act - const checksum: string = service.superGenerateChecksum(callName, urlSearchParams); + const checksum = service.superGenerateChecksum(callName, urlSearchParams); - // Assert expect(checksum).toEqual(expectedChecksum); expect(createHashMock).toBeCalledWith('sha1'); }); it('getUrl: should return composed url', () => { - // Arrange - const createConfig = createBBBCreateConfig(); - const callName = 'create'; - const params: URLSearchParams = service.superToParams(createConfig); + const { callName, createConfig } = setup(); + const params = service.superToParams(createConfig); - // Act - const url: string = service.superGetUrl(callName, params); + const url = service.superGetUrl(callName, params); - // Assert expect(url.toString()).toContain(`${service.getBaseUrl()}/bigbluebutton/api/${callName}`); expect(url.includes('checksum')).toBeTruthy(); }); diff --git a/apps/server/src/modules/video-conference/bbb/bbb.service.ts b/apps/server/src/modules/video-conference/bbb/bbb.service.ts index 2fcc3db9981..86e2f972ef3 100644 --- a/apps/server/src/modules/video-conference/bbb/bbb.service.ts +++ b/apps/server/src/modules/video-conference/bbb/bbb.service.ts @@ -1,42 +1,56 @@ import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; -import { ConverterUtil } from '@shared/common/utils'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ErrorUtils } from '@src/core/error/utils'; import { AxiosResponse } from 'axios'; import crypto from 'crypto'; import { firstValueFrom, Observable } from 'rxjs'; import { URL, URLSearchParams } from 'url'; -import { BbbSettings, IBbbSettings } from './bbb-settings.interface'; +import xml2json from '@hendt/xml2json/lib'; +import { VideoConferenceConfig } from '../video-conference-config'; +import { BbbConfig } from './bbb-config'; import { BBBBaseMeetingConfig, BBBCreateConfig, BBBJoinConfig } from './request'; import { BBBBaseResponse, BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBStatus } from './response'; @Injectable() export class BBBService { constructor( - @Inject(BbbSettings) private readonly bbbSettings: IBbbSettings, - private readonly httpService: HttpService, - private readonly converterUtil: ConverterUtil + private readonly configService: ConfigService, + private readonly httpService: HttpService ) {} protected get baseUrl(): string { - return this.bbbSettings.host; + return this.configService.get('VIDEOCONFERENCE_HOST'); } protected get salt(): string { - return this.bbbSettings.salt; + return this.configService.get('VIDEOCONFERENCE_SALT'); } protected get presentationUrl(): string { - return this.bbbSettings.presentationUrl; + return this.configService.get('VIDEOCONFERENCE_DEFAULT_PRESENTATION'); + } + + /** Note no guard, or type check. Should be private. */ + public xml2object(xml: string): T { + const json = xml2json(xml) as T; + + return json; + } + + private checkIfResponseSucces( + bbbResp: BBBResponse | BBBResponse | BBBResponse + ): void { + if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { + throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); + } } /** * Creates a new BBB Meeting. The create call is idempotent: you can call it multiple times with the same parameters without side effects. - * @param {BBBCreateConfig} config - * @returns {Promise>} * @throws {InternalServerErrorException} */ - create(config: BBBCreateConfig): Promise> { + public create(config: BBBCreateConfig): Promise> { const url: string = this.getUrl('create', this.toParams(config)); const conf = { headers: { 'Content-Type': 'application/xml' } }; const data = this.getBbbRequestConfig(this.presentationUrl); @@ -44,13 +58,10 @@ export class BBBService { return firstValueFrom(observable) .then((resp: AxiosResponse) => { - const bbbResp = this.converterUtil.xml2object | BBBResponse>( - resp.data - ); - if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); - } - return bbbResp as BBBResponse; + const bbbResp = this.xml2object>(resp.data); + this.checkIfResponseSucces(bbbResp); + + return bbbResp; }) .catch((error) => { throw new InternalServerErrorException(null, ErrorUtils.createHttpExceptionOptions(error, 'BBBService:create')); @@ -58,7 +69,7 @@ export class BBBService { } // it should be a private method - getBbbRequestConfig(presentationUrl: string): string { + private getBbbRequestConfig(presentationUrl: string): string { if (presentationUrl === '') return ''; return ``; } @@ -69,7 +80,7 @@ export class BBBService { * @returns {Promise} The join url * @throws {InternalServerErrorException} */ - async join(config: BBBJoinConfig): Promise { + public async join(config: BBBJoinConfig): Promise { await this.getMeetingInfo(new BBBBaseMeetingConfig({ meetingID: config.meetingID })); return this.getUrl('join', this.toParams(config)); @@ -81,16 +92,15 @@ export class BBBService { * @returns {BBBResponse} * @throws {InternalServerErrorException} */ - end(config: BBBBaseMeetingConfig): Promise> { + public end(config: BBBBaseMeetingConfig): Promise> { const url: string = this.getUrl('end', this.toParams(config)); const observable: Observable> = this.httpService.get(url); return firstValueFrom(observable) .then((resp: AxiosResponse) => { - const bbbResp = this.converterUtil.xml2object>(resp.data); - if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); - } + const bbbResp = this.xml2object>(resp.data); + this.checkIfResponseSucces(bbbResp); + return bbbResp; }) .catch((error) => { @@ -100,23 +110,18 @@ export class BBBService { /** * Returns information about a BBB Meeting. - * @param {BBBBaseMeetingConfig} config - * @returns {Promise} * @throws {InternalServerErrorException} */ - getMeetingInfo(config: BBBBaseMeetingConfig): Promise> { + public getMeetingInfo(config: BBBBaseMeetingConfig): Promise> { const url: string = this.getUrl('getMeetingInfo', this.toParams(config)); const observable: Observable> = this.httpService.get(url); return firstValueFrom(observable) .then((resp: AxiosResponse) => { - const bbbResp = this.converterUtil.xml2object< - BBBResponse | BBBResponse - >(resp.data); - if (bbbResp.response.returncode !== BBBStatus.SUCCESS) { - throw new InternalServerErrorException(`${bbbResp.response.messageKey}: ${bbbResp.response.message}`); - } - return bbbResp as BBBResponse; + const bbbResp = this.xml2object>(resp.data); + this.checkIfResponseSucces(bbbResp); + + return bbbResp; }) .catch((error) => { throw new InternalServerErrorException( @@ -126,26 +131,22 @@ export class BBBService { }); } - // should be private /** * Returns a SHA1 encoded checksum for the input parameters. - * @param {string} callName - * @param {URLSearchParams} queryParams - * @returns {string} + * should be private */ protected generateChecksum(callName: string, queryParams: URLSearchParams): string { const queryString: string = queryParams.toString(); const sha = crypto.createHash('sha1'); sha.update(callName + queryString + this.salt); const checksum: string = sha.digest('hex'); + return checksum; } - // should be private /** * Extracts fields from a javascript object and builds a URLSearchParams object from it. - * @param {object} object - * @returns {URLSearchParams} + * should be private */ protected toParams(object: BBBCreateConfig | BBBBaseMeetingConfig): URLSearchParams { const params: URLSearchParams = new URLSearchParams(); @@ -154,15 +155,13 @@ export class BBBService { params.append(key, String(object[key])); } }); + return params; } - // should be private /** * Builds the url for BBB. - * @param callName Name of the BBB api function. - * @param queryParams Parameters for the endpoint. - * @returns {string} A callable url. + * should be private */ protected getUrl(callName: string, queryParams: URLSearchParams): string { const checksum: string = this.generateChecksum(callName, queryParams); diff --git a/apps/server/src/modules/video-conference/bbb/index.ts b/apps/server/src/modules/video-conference/bbb/index.ts index 66f3703a950..cc35a1e2d07 100644 --- a/apps/server/src/modules/video-conference/bbb/index.ts +++ b/apps/server/src/modules/video-conference/bbb/index.ts @@ -1,5 +1,5 @@ -export * from './bbb-settings.interface'; export * from './request'; export * from './builder'; export * from './response'; export * from './bbb.service'; +export { BbbConfig } from './bbb-config'; diff --git a/apps/server/src/modules/video-conference/index.ts b/apps/server/src/modules/video-conference/index.ts index 16071ac6627..7564a72653f 100644 --- a/apps/server/src/modules/video-conference/index.ts +++ b/apps/server/src/modules/video-conference/index.ts @@ -1,3 +1,2 @@ export { VideoConferenceModule } from './video-conference.module'; -export { IVideoConferenceSettings } from './interface'; -export { default as VideoConferenceConfiguration } from './video-conference-config'; +export { VideoConferenceConfig } from './video-conference-config'; diff --git a/apps/server/src/modules/video-conference/interface/index.ts b/apps/server/src/modules/video-conference/interface/index.ts index 5df41ce67c3..6c797da8dc5 100644 --- a/apps/server/src/modules/video-conference/interface/index.ts +++ b/apps/server/src/modules/video-conference/interface/index.ts @@ -1,2 +1 @@ -export * from './video-conference-settings.interface'; export * from './video-conference-options.interface'; diff --git a/apps/server/src/modules/video-conference/interface/video-conference-settings.interface.ts b/apps/server/src/modules/video-conference/interface/video-conference-settings.interface.ts deleted file mode 100644 index a1d07fb516a..00000000000 --- a/apps/server/src/modules/video-conference/interface/video-conference-settings.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IBbbSettings } from '../bbb'; - -export const VideoConferenceSettings = Symbol('VideoConferenceSettings'); - -export interface IVideoConferenceSettings { - enabled: boolean; - hostUrl: string; - bbb: IBbbSettings; -} diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index d0daef948aa..3bc50b9f20f 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -1,11 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { CalendarEventDto, CalendarService } from '@infra/calendar'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { CourseService } from '@modules/learnroom/service'; import { LegacySchoolService } from '@modules/legacy-school'; import { UserService } from '@modules/user'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO, VideoConferenceDO } from '@shared/domain/domainobject'; import { Course, TeamUserEntity } from '@shared/domain/entity'; @@ -16,14 +18,14 @@ import { courseFactory, roleFactory, setupEntities, userDoFactory, userFactory } import { teamFactory } from '@shared/testing/factory/team.factory'; import { teamUserFactory } from '@shared/testing/factory/teamuser.factory'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; -import { ObjectId } from '@mikro-orm/mongodb'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; -import { IVideoConferenceSettings, VideoConferenceOptions, VideoConferenceSettings } from '../interface'; +import { VideoConferenceOptions } from '../interface'; import { ScopeInfo, ScopeRef, VideoConferenceState } from '../uc/dto'; +import { VideoConferenceConfig } from '../video-conference-config'; import { VideoConferenceService } from './video-conference.service'; -describe('VideoConferenceService', () => { +describe(VideoConferenceService.name, () => { let service: DeepMocked; let courseService: DeepMocked; let calendarService: DeepMocked; @@ -32,17 +34,15 @@ describe('VideoConferenceService', () => { let teamsRepo: DeepMocked; let userService: DeepMocked; let videoConferenceRepo: DeepMocked; - let videoConferenceSettings: DeepMocked; + let configService: DeepMocked>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ VideoConferenceService, { - provide: VideoConferenceSettings, - useValue: createMock({ - hostUrl: 'https://api.example.com', - }), + provide: ConfigService, + useValue: createMock>(), }, { provide: CourseService, @@ -83,13 +83,15 @@ describe('VideoConferenceService', () => { teamsRepo = module.get(TeamsRepo); userService = module.get(UserService); videoConferenceRepo = module.get(VideoConferenceRepo); - videoConferenceSettings = module.get(VideoConferenceSettings); + configService = module.get(ConfigService); await setupEntities(); }); describe('canGuestJoin', () => { const setup = (isGuest: boolean, state: VideoConferenceState, waitingRoomEnabled: boolean) => { + configService.get.mockReturnValue('https://api.example.com'); + return { isGuest, state, @@ -139,6 +141,7 @@ describe('VideoConferenceService', () => { const userId = user.id as EntityId; const scopeId = new ObjectId().toHexString(); + configService.get.mockReturnValue('https://api.example.com'); userService.findById.mockResolvedValue(user); return { @@ -492,7 +495,8 @@ describe('VideoConferenceService', () => { describe('when video conference feature is globally disabled', () => { it('should throw a ForbiddenException', async () => { const { schoolId } = setup(false); - videoConferenceSettings.enabled = false; + + configService.get.mockReturnValue(false); const func = () => service.throwOnFeaturesDisabled(schoolId); @@ -540,6 +544,8 @@ describe('VideoConferenceService', () => { const conferenceScope: VideoConferenceScope = VideoConferenceScope.COURSE; const scopeId = new ObjectId().toHexString(); + configService.get.mockReturnValue('https://api.example.com'); + return { userId, conferenceScope, @@ -605,6 +611,8 @@ describe('VideoConferenceService', () => { .withRoleAndUserId(roleFactory.build({ name: RoleName.EXPERT }), new ObjectId().toHexString()) .build(); + configService.get.mockReturnValue('https://api.example.com'); + return { user, userId, diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.ts b/apps/server/src/modules/video-conference/service/video-conference.service.ts index 13604e32b12..f910e9e215b 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.ts @@ -3,7 +3,8 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/auth import { CourseService } from '@modules/learnroom'; import { LegacySchoolService } from '@modules/legacy-school'; import { UserService } from '@modules/user'; -import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { RoleReference, UserDO, VideoConferenceDO, VideoConferenceOptionsDO } from '@shared/domain/domainobject'; import { Course, TeamEntity, TeamUserEntity, User } from '@shared/domain/entity'; import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; @@ -11,13 +12,14 @@ import { EntityId, SchoolFeature } from '@shared/domain/types'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; -import { IVideoConferenceSettings, VideoConferenceOptions, VideoConferenceSettings } from '../interface'; +import { VideoConferenceOptions } from '../interface'; import { ScopeInfo, VideoConferenceState } from '../uc/dto'; +import { VideoConferenceConfig } from '../video-conference-config'; @Injectable() export class VideoConferenceService { constructor( - @Inject(VideoConferenceSettings) private readonly vcSettings: IVideoConferenceSettings, + private readonly configService: ConfigService, private readonly courseService: CourseService, private readonly calendarService: CalendarService, private readonly authorizationService: AuthorizationService, @@ -28,21 +30,25 @@ export class VideoConferenceService { ) {} get hostUrl(): string { - return this.vcSettings.hostUrl; + return this.configService.get('HOST'); } get isVideoConferenceFeatureEnabled(): boolean { - return this.vcSettings.enabled; + return this.configService.get('FEATURE_VIDEOCONFERENCE_ENABLED'); } - canGuestJoin(isGuest: boolean, state: VideoConferenceState, waitingRoomEnabled: boolean): boolean { + public canGuestJoin(isGuest: boolean, state: VideoConferenceState, waitingRoomEnabled: boolean): boolean { if ((isGuest && state === VideoConferenceState.NOT_STARTED) || (isGuest && !waitingRoomEnabled)) { return false; } return true; } - async hasExpertRole(userId: EntityId, conferenceScope: VideoConferenceScope, scopeId: string): Promise { + public async hasExpertRole( + userId: EntityId, + conferenceScope: VideoConferenceScope, + scopeId: string + ): Promise { let isExpert = false; switch (conferenceScope) { case VideoConferenceScope.COURSE: { @@ -136,7 +142,7 @@ export class VideoConferenceService { throw new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION); } - async throwOnFeaturesDisabled(schoolId: EntityId): Promise { + public async throwOnFeaturesDisabled(schoolId: EntityId): Promise { if (!this.isVideoConferenceFeatureEnabled) { throw new ForbiddenException( ErrorStatus.SCHOOL_FEATURE_DISABLED, @@ -150,11 +156,11 @@ export class VideoConferenceService { } } - sanitizeString(text: string) { + public sanitizeString(text: string) { return text.replace(/[^\dA-Za-zÀ-ÖØ-öø-ÿ.\-=_`´ ]/g, ''); } - async getScopeInfo(userId: EntityId, scopeId: string, scope: VideoConferenceScope): Promise { + public async getScopeInfo(userId: EntityId, scopeId: string, scope: VideoConferenceScope): Promise { switch (scope) { case VideoConferenceScope.COURSE: { const course: Course = await this.courseService.findById(scopeId); @@ -181,7 +187,7 @@ export class VideoConferenceService { } } - async getUserRoleAndGuestStatusByUserIdForBbb( + public async getUserRoleAndGuestStatusByUserIdForBbb( userId: string, scopeId: EntityId, scope: VideoConferenceScope @@ -195,7 +201,7 @@ export class VideoConferenceService { return { role, isGuest: isBbbGuest }; } - async findVideoConferenceByScopeIdAndScope( + public async findVideoConferenceByScopeIdAndScope( scopeId: EntityId, scope: VideoConferenceScope ): Promise { @@ -204,7 +210,7 @@ export class VideoConferenceService { return videoConference; } - async createOrUpdateVideoConferenceForScopeWithOptions( + public async createOrUpdateVideoConferenceForScopeWithOptions( scopeId: EntityId, scope: VideoConferenceScope, options: VideoConferenceOptions diff --git a/apps/server/src/modules/video-conference/video-conference-config.ts b/apps/server/src/modules/video-conference/video-conference-config.ts index 7e65d0e7155..0753cd811eb 100644 --- a/apps/server/src/modules/video-conference/video-conference-config.ts +++ b/apps/server/src/modules/video-conference/video-conference-config.ts @@ -1,17 +1,4 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { IBbbSettings } from './bbb'; -import { IVideoConferenceSettings } from './interface'; - -export default class VideoConferenceConfiguration { - static bbb: IBbbSettings = { - host: Configuration.get('VIDEOCONFERENCE_HOST') as string, - salt: Configuration.get('VIDEOCONFERENCE_SALT') as string, - presentationUrl: Configuration.get('VIDEOCONFERENCE_DEFAULT_PRESENTATION') as string, - }; - - static videoConference: IVideoConferenceSettings = { - enabled: Configuration.get('FEATURE_VIDEOCONFERENCE_ENABLED') as boolean, - hostUrl: Configuration.get('HOST') as string, - bbb: VideoConferenceConfiguration.bbb, - }; +export interface VideoConferenceConfig { + HOST: string; + FEATURE_VIDEOCONFERENCE_ENABLED: boolean; } diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index db0a539e5fc..72c2be9fb6e 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -5,17 +5,14 @@ import { LegacySchoolModule } from '@modules/legacy-school'; import { UserModule } from '@modules/user'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { ConverterUtil } from '@shared/common'; import { TeamsRepo } from '@shared/repo'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { LoggerModule } from '@src/core/logger'; import { LearnroomModule } from '../learnroom'; -import { BBBService, BbbSettings } from './bbb'; +import { BBBService } from './bbb'; import { VideoConferenceDeprecatedController } from './controller'; -import { VideoConferenceSettings } from './interface'; import { VideoConferenceService } from './service'; import { VideoConferenceDeprecatedUc } from './uc'; -import VideoConferenceConfiguration from './video-conference-config'; @Module({ imports: [ @@ -30,19 +27,10 @@ import VideoConferenceConfiguration from './video-conference-config'; UserModule, ], providers: [ - { - provide: VideoConferenceSettings, - useValue: VideoConferenceConfiguration.videoConference, - }, - { - provide: BbbSettings, - useValue: VideoConferenceConfiguration.bbb, - }, BBBService, VideoConferenceRepo, // TODO: N21-1010 clean up video conferences - remove repos TeamsRepo, - ConverterUtil, VideoConferenceService, // TODO: N21-885 remove VideoConferenceDeprecatedUc from providers VideoConferenceDeprecatedUc, diff --git a/apps/server/src/shared/common/error/index.ts b/apps/server/src/shared/common/error/index.ts index d82dbc40fbb..58446694939 100644 --- a/apps/server/src/shared/common/error/index.ts +++ b/apps/server/src/shared/common/error/index.ts @@ -4,6 +4,7 @@ export * from './business.error'; export * from './entity-not-found.error'; export * from './forbidden-operation.error'; export * from './validation.error'; +export * from './interfaces'; // business errors export * from './user-already-assigned-to-import-user.business-error'; diff --git a/apps/server/src/shared/common/error/interfaces.ts b/apps/server/src/shared/common/error/interfaces.ts new file mode 100644 index 00000000000..e9f58d88279 --- /dev/null +++ b/apps/server/src/shared/common/error/interfaces.ts @@ -0,0 +1,12 @@ +export type ErrorLogMessage = { + error?: Error; + type: string; // TODO: use enum + stack?: string; + data?: { [key: string]: string | number | boolean | undefined }; +}; + +export type ValidationErrorLogMessage = { + validationErrors: string[]; + stack?: string; + type: string; // TODO: use enum +}; diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts index 9f129244685..fdf31d42889 100644 --- a/apps/server/src/shared/common/guards/type.guard.spec.ts +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -212,4 +212,96 @@ describe('TypeGuard', () => { }); }); }); + + describe('isNull', () => { + describe('when passing type of value is null', () => { + it('should be return true', () => { + expect(TypeGuard.isNull(null)).toBe(true); + }); + }); + + describe('when passing type of value is NOT null', () => { + it('should be return false', () => { + expect(TypeGuard.isNull(undefined)).toBe(false); + }); + + it('should be return true', () => { + expect(TypeGuard.isNull('string')).toBe(false); + }); + + it('should be return true', () => { + expect(TypeGuard.isNull('')).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isNull({})).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isNull(1)).toBe(false); + }); + }); + }); + + describe('isUndefined', () => { + describe('when passing type of value is undefined', () => { + it('should be return true', () => { + expect(TypeGuard.isUndefined(undefined)).toBe(true); + }); + }); + + describe('when passing type of value is NOT undefined', () => { + it('should be return false', () => { + expect(TypeGuard.isUndefined(null)).toBe(false); + }); + + it('should be return true', () => { + expect(TypeGuard.isUndefined('string')).toBe(false); + }); + + it('should be return true', () => { + expect(TypeGuard.isUndefined('')).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isUndefined({})).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isUndefined(1)).toBe(false); + }); + }); + }); + + describe('checkNotNullOrUndefined', () => { + describe('when value is null', () => { + it('should throw error if it is passed', () => { + expect(() => TypeGuard.checkNotNullOrUndefined(null, new Error('Test'))).toThrow('Test'); + }); + + it('should throw default error if not error passed', () => { + expect(() => TypeGuard.checkNotNullOrUndefined(null)).toThrow('Type is null.'); + }); + }); + + describe('when value is undefined', () => { + it('should throw error if it is passed', () => { + expect(() => TypeGuard.checkNotNullOrUndefined(undefined, new Error('Test'))).toThrow('Test'); + }); + + it('should throw default error if not error passed', () => { + expect(() => TypeGuard.checkNotNullOrUndefined(undefined)).toThrow('Type is undefined.'); + }); + }); + + describe('when value is defined', () => { + it('should return value if error is passed', () => { + expect(TypeGuard.checkNotNullOrUndefined('', new Error('Test'))).toBe(''); + }); + + it('should return value', () => { + expect(TypeGuard.checkNotNullOrUndefined('')).toBe(''); + }); + }); + }); }); diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index 171ca40b596..f7500085d2f 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -46,4 +46,28 @@ export class TypeGuard { return isObject; } + + static isNull(value: unknown): value is null { + const isNull = value === null; + + return isNull; + } + + static isUndefined(value: unknown): value is undefined { + const isUndefined = value === undefined; + + return isUndefined; + } + + static checkNotNullOrUndefined(value: T | null | undefined, toThrow?: Error): T { + if (TypeGuard.isNull(value)) { + throw toThrow || new Error('Type is null.'); + } + + if (TypeGuard.isUndefined(value)) { + throw toThrow || new Error('Type is undefined.'); + } + + return value; + } } diff --git a/apps/server/src/shared/common/loggable/index.ts b/apps/server/src/shared/common/loggable/index.ts index 5f21a462625..41cf4b21ac5 100644 --- a/apps/server/src/shared/common/loggable/index.ts +++ b/apps/server/src/shared/common/loggable/index.ts @@ -1 +1,2 @@ export { ReferencedEntityNotFoundLoggable } from './referenced-entity-not-found-loggable'; +export { Loggable, LoggableMessage } from './interfaces'; diff --git a/apps/server/src/shared/common/loggable/interfaces.ts b/apps/server/src/shared/common/loggable/interfaces.ts new file mode 100644 index 00000000000..359b5f37ad9 --- /dev/null +++ b/apps/server/src/shared/common/loggable/interfaces.ts @@ -0,0 +1,18 @@ +import { ErrorLogMessage, ValidationErrorLogMessage } from '../error'; + +type LogMessageDataObject = { + [key: string]: LogMessageData; +}; + +type LogMessageData = LogMessageDataObject | string | number | boolean | undefined; + +type LogMessage = { + message: string; + data?: LogMessageData; +}; + +export type LoggableMessage = LogMessage | ErrorLogMessage | ValidationErrorLogMessage; + +export interface Loggable { + getLogMessage(): LoggableMessage; +} diff --git a/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.ts b/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.ts index 10031eeb67e..2a6e33c97ff 100644 --- a/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.ts +++ b/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.ts @@ -1,5 +1,5 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; import { EntityId } from '../../domain/types'; +import { Loggable, LoggableMessage } from './interfaces'; export class ReferencedEntityNotFoundLoggable implements Loggable { constructor( @@ -9,7 +9,7 @@ export class ReferencedEntityNotFoundLoggable implements Loggable { private readonly referencedEntityId: EntityId ) {} - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LoggableMessage { return { message: 'The requested entity could not been found, but it is still referenced.', data: { diff --git a/apps/server/src/shared/common/measure-utils/index.ts b/apps/server/src/shared/common/measure-utils/index.ts new file mode 100644 index 00000000000..e217f54ad43 --- /dev/null +++ b/apps/server/src/shared/common/measure-utils/index.ts @@ -0,0 +1 @@ +export * from './performance-observer'; diff --git a/apps/server/src/shared/common/measure-utils/performance-observer.spec.ts b/apps/server/src/shared/common/measure-utils/performance-observer.spec.ts new file mode 100644 index 00000000000..668ffc7ff29 --- /dev/null +++ b/apps/server/src/shared/common/measure-utils/performance-observer.spec.ts @@ -0,0 +1,103 @@ +import { PerformanceEntry } from 'node:perf_hooks'; +import { + InitialisePerformanceObserverLoggable, + MeasuresLoggable, + initialisePerformanceObserver, + closePerformanceObserver, +} from './performance-observer'; + +async function wait(timeoutMS: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, timeoutMS); + }); +} + +async function waitForEventLoopEnd(): Promise { + return new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); +} + +describe('PerformanceObserver', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('InitialisePerformanceObserverLoggable', () => { + describe('getLogMessage', () => { + it('should be log correct formated message', () => { + const loggable = new InitialisePerformanceObserverLoggable(); + + const log = loggable.getLogMessage(); + + expect(log).toEqual({ + message: 'Initialise PerformanceObserver...', + }); + }); + }); + }); + + describe('MeasuresLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const performanceEntry = { name: 'a', duration: 1, detail: { x: 1 } } as PerformanceEntry; + + const loggable = new MeasuresLoggable([performanceEntry, performanceEntry]); + + return { loggable }; + }; + + it('should be log correct formated message', () => { + const { loggable } = setup(); + + const log = loggable.getLogMessage(); + + expect(log).toEqual({ + message: 'Measure results', + data: '[{ location: a, duration: 1, detail: { x: 1 } }, { location: a, duration: 1, detail: { x: 1 } }]', + }); + }); + }); + }); + + describe('initialisePerformanceObserver', () => { + const setup = () => { + const mockInfoLogger = { + info: () => {}, + }; + const infoLoggerSpy = jest.spyOn(mockInfoLogger, 'info'); + + return { infoLoggerSpy, mockInfoLogger }; + }; + + it('should be log by execution', () => { + const { infoLoggerSpy, mockInfoLogger } = setup(); + + initialisePerformanceObserver(mockInfoLogger); + + expect(infoLoggerSpy).toHaveBeenNthCalledWith(1, new InitialisePerformanceObserverLoggable()); + + closePerformanceObserver(); + }); + + it('should be log messure if it is executed', async () => { + const { infoLoggerSpy, mockInfoLogger } = setup(); + initialisePerformanceObserver(mockInfoLogger); + infoLoggerSpy.mockClear(); + + performance.mark('startMark'); + await wait(1); + performance.measure('myMeasure', { + start: 'startMark', + detail: { x: 1 }, + }); + + await waitForEventLoopEnd(); + expect(infoLoggerSpy).toHaveBeenNthCalledWith(1, new MeasuresLoggable([])); + + closePerformanceObserver(); + }); + }); +}); diff --git a/apps/server/src/shared/common/measure-utils/performance-observer.ts b/apps/server/src/shared/common/measure-utils/performance-observer.ts new file mode 100644 index 00000000000..6b052b0fd3a --- /dev/null +++ b/apps/server/src/shared/common/measure-utils/performance-observer.ts @@ -0,0 +1,56 @@ +import { PerformanceEntry, PerformanceObserver } from 'node:perf_hooks'; +import util from 'util'; +import { Loggable, LoggableMessage } from '../loggable'; + +interface InfoLogger { + info(input: Loggable): void; +} + +export class MeasuresLoggable implements Loggable { + constructor(private readonly entries: PerformanceEntry[]) {} + + getLogMessage(): LoggableMessage { + const stringifiedEntries = this.entries.map((entry) => { + const detail = util.inspect(entry.detail).replace(/\n/g, '').replace(/\\n/g, ''); + return `{ location: ${entry.name}, duration: ${entry.duration}, detail: ${detail} }`; + }); + const data = `[${stringifiedEntries.join(', ')}]`; + const message = { message: `Measure results`, data }; + + return message; + } +} + +export class InitialisePerformanceObserverLoggable implements Loggable { + getLogMessage(): LoggableMessage { + return { + message: 'Initialise PerformanceObserver...', + }; + } +} + +let observer: PerformanceObserver | null = null; + +export const initialisePerformanceObserver = (infoLogger: InfoLogger): PerformanceObserver => { + infoLogger.info(new InitialisePerformanceObserverLoggable()); + + if (observer === null) { + observer = new PerformanceObserver((perfObserverEntryList) => { + const entries = perfObserverEntryList.getEntriesByType('measure'); + infoLogger.info(new MeasuresLoggable(entries)); + }); + + observer.observe({ type: 'measure', buffered: true }); + } + + return observer; +}; + +export const closePerformanceObserver = (): void => { + if (observer !== null) { + performance.clearMarks(); + performance.clearMeasures(); + observer.disconnect(); + observer = null; + } +}; diff --git a/apps/server/src/shared/common/utils/converter.util.spec.ts b/apps/server/src/shared/common/utils/converter.util.spec.ts deleted file mode 100644 index 004166c7c28..00000000000 --- a/apps/server/src/shared/common/utils/converter.util.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ConverterUtil } from '@shared/common'; - -class TestObject { - test: string; - - constructor(test: string) { - this.test = test; - } -} -describe('ConverterUtil', () => { - let service: ConverterUtil; - beforeAll(() => { - service = new ConverterUtil(); - }); - describe('xml2Object', () => { - it('should map correctly to TestObject', () => { - const test = 'test'; - const ret = service.xml2object(test); - expect(ret.test).toEqual('test'); - }); - }); -}); diff --git a/apps/server/src/shared/common/utils/converter.util.ts b/apps/server/src/shared/common/utils/converter.util.ts deleted file mode 100644 index 814bdfda096..00000000000 --- a/apps/server/src/shared/common/utils/converter.util.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import xml2json from '@hendt/xml2json'; - -/** - * This class encapsulates - */ -@Injectable() -export class ConverterUtil { - xml2object(xml: string): T { - return xml2json(xml) as T; - } -} diff --git a/apps/server/src/shared/common/utils/guard-against.spec.ts b/apps/server/src/shared/common/utils/guard-against.spec.ts deleted file mode 100644 index 0f46e6e96b6..00000000000 --- a/apps/server/src/shared/common/utils/guard-against.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { GuardAgainst } from '@shared/common'; - -describe('GuardAgainst', () => { - describe('nullOrUndefined', () => { - describe('when value is null', () => { - const error = new Error('value is null'); - - it('should throw', () => { - const value: string | null = null; - expect(() => GuardAgainst.nullOrUndefined(value, error)).toThrow(error.message); - }); - }); - - describe('when value is undefined', () => { - const error = new Error('value is undefined'); - - it('should throw', () => { - const value: string | undefined = undefined; - expect(() => GuardAgainst.nullOrUndefined(value, error)).toThrow(error.message); - }); - }); - - describe('when value is defined', () => { - const error = new Error('value is null'); - - it('should return value', () => { - const value = ''; - expect(GuardAgainst.nullOrUndefined(value, error)).toBe(''); - }); - }); - }); -}); diff --git a/apps/server/src/shared/common/utils/guard-against.ts b/apps/server/src/shared/common/utils/guard-against.ts deleted file mode 100644 index 6425399681a..00000000000 --- a/apps/server/src/shared/common/utils/guard-against.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class GuardAgainst { - /** - * Guards against null or undefined and throws specified exception. - * @param value The value to check. - * @param toThrow The exception to be thrown on failure. - * @returns The narrowed value or throws. - */ - static nullOrUndefined(value: T | null | undefined, toThrow: unknown): T | never { - if (value === null || value === undefined) { - throw toThrow; - } - return value; - } -} diff --git a/apps/server/src/shared/common/utils/index.ts b/apps/server/src/shared/common/utils/index.ts index 90dadf998ed..6c6e6ffe2be 100644 --- a/apps/server/src/shared/common/utils/index.ts +++ b/apps/server/src/shared/common/utils/index.ts @@ -1,3 +1,3 @@ -export * from './converter.util'; -export * from './guard-against'; export { SortHelper } from './sort-helper'; +export { getResolvedValues, isFulfilled } from './promise'; +export { extractJwtFromHeader, JwtExtractor } from './jwt'; diff --git a/apps/server/src/modules/authentication/helper/jwt-extractor.spec.ts b/apps/server/src/shared/common/utils/jwt.spec.ts similarity index 95% rename from apps/server/src/modules/authentication/helper/jwt-extractor.spec.ts rename to apps/server/src/shared/common/utils/jwt.spec.ts index 8186287171a..f0d9a74f22f 100644 --- a/apps/server/src/modules/authentication/helper/jwt-extractor.spec.ts +++ b/apps/server/src/shared/common/utils/jwt.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Request } from 'express'; import { JwtFromRequestFunction } from 'passport-jwt'; -import { JwtExtractor } from './jwt-extractor'; +import { JwtExtractor } from './jwt'; describe('JwtExtractor', () => { let request: DeepMocked; diff --git a/apps/server/src/modules/authentication/helper/jwt-extractor.ts b/apps/server/src/shared/common/utils/jwt.ts similarity index 60% rename from apps/server/src/modules/authentication/helper/jwt-extractor.ts rename to apps/server/src/shared/common/utils/jwt.ts index d54807c2ac2..ebc589236dc 100644 --- a/apps/server/src/modules/authentication/helper/jwt-extractor.ts +++ b/apps/server/src/shared/common/utils/jwt.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { JwtFromRequestFunction } from 'passport-jwt'; +import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt'; import cookie from 'cookie'; export class JwtExtractor { @@ -7,12 +7,15 @@ export class JwtExtractor { return (request: Request) => { let token: string | null = null; const cookies = cookie.parse(request.headers.cookie || ''); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (cookies && cookies[name]) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access token = cookies[name]; } return token; }; } } + +export const extractJwtFromHeader = ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + JwtExtractor.fromCookie('jwt'), +]); diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 7dc369e2f04..f45b68d865f 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -1,20 +1,21 @@ import { AccountEntity } from '@modules/account/domain/entity/account.entity'; import { BoardNodeEntity } from '@modules/board/repo/entity'; import { ClassEntity } from '@modules/class/entity'; +import { DeletionLogEntity } from '@modules/deletion/repo/entity/deletion-log.entity'; +import { DeletionRequestEntity } from '@modules/deletion/repo/entity/deletion-request.entity'; import { GroupEntity } from '@modules/group/entity'; import { InstanceEntity } from '@modules/instance'; import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; import { RegistrationPinEntity } from '@modules/registration-pin/entity'; +import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; +import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; 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 { MediaSourceEntity, MediaUserLicenseEntity, UserLicenseEntity } from '@modules/user-license/entity'; -import { DeletionLogEntity } from '@modules/deletion/repo/entity/deletion-log.entity'; -import { DeletionRequestEntity } from '@modules/deletion/repo/entity/deletion-request.entity'; -import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { ColumnBoardNode } from './column-board-node.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; @@ -37,7 +38,6 @@ import { SchoolEntity, SchoolRolePermission, SchoolRoles } from './school.entity import { SchoolYearEntity } from './schoolyear.entity'; import { StorageProviderEntity } from './storageprovider.entity'; import { Submission } from './submission.entity'; -import { SystemEntity } from './system.entity'; import { Task } from './task.entity'; import { TeamEntity, TeamUserEntity } from './team.entity'; import { UserLoginMigrationEntity } from './user-login-migration.entity'; diff --git a/apps/server/src/shared/domain/entity/external-source.embeddable.ts b/apps/server/src/shared/domain/entity/external-source.embeddable.ts index 4c5dfe2ac20..6326308a867 100644 --- a/apps/server/src/shared/domain/entity/external-source.embeddable.ts +++ b/apps/server/src/shared/domain/entity/external-source.embeddable.ts @@ -1,5 +1,5 @@ import { Embeddable, ManyToOne, Property } from '@mikro-orm/core'; -import { SystemEntity } from './system.entity'; +import { SystemEntity } from '@modules/system/entity/system.entity'; export interface ExternalSourceEntityProps { externalId: string; diff --git a/apps/server/src/shared/domain/entity/import-user.entity.ts b/apps/server/src/shared/domain/entity/import-user.entity.ts index 78aa5036cb4..24a268ccb5e 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.ts +++ b/apps/server/src/shared/domain/entity/import-user.entity.ts @@ -1,8 +1,8 @@ import { Entity, Enum, IdentifiedReference, ManyToOne, Property, Unique, wrap } from '@mikro-orm/core'; +import { SystemEntity } from '@modules/system/entity/system.entity'; import { EntityWithSchool, RoleName } from '../interface'; import { BaseEntityReference, BaseEntityWithTimestamps } from './base.entity'; import { SchoolEntity } from './school.entity'; -import { SystemEntity } from './system.entity'; import type { User } from './user.entity'; export type IImportUserRoleName = RoleName.ADMINISTRATOR | RoleName.TEACHER | RoleName.STUDENT; diff --git a/apps/server/src/shared/domain/entity/index.ts b/apps/server/src/shared/domain/entity/index.ts index 9d023584dba..99349a0649e 100644 --- a/apps/server/src/shared/domain/entity/index.ts +++ b/apps/server/src/shared/domain/entity/index.ts @@ -17,7 +17,6 @@ export * from './school.entity'; export * from './schoolyear.entity'; export * from './storageprovider.entity'; export * from './submission.entity'; -export * from './system.entity'; export * from './task.entity'; export * from './team.entity'; export * from './user-login-migration.entity'; diff --git a/apps/server/src/shared/domain/entity/school.entity.ts b/apps/server/src/shared/domain/entity/school.entity.ts index 0d86a420c9d..f152f420fdb 100644 --- a/apps/server/src/shared/domain/entity/school.entity.ts +++ b/apps/server/src/shared/domain/entity/school.entity.ts @@ -12,6 +12,7 @@ import { Property, } from '@mikro-orm/core'; import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; +import { SystemEntity } from '@modules/system/entity/system.entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; import { FileStorageType } from '@src/modules/school/domain/type/file-storage-type.enum'; @@ -19,7 +20,6 @@ import { LanguageType } from '../interface'; import { BaseEntityWithTimestamps } from './base.entity'; import { CountyEmbeddable, FederalStateEntity } from './federal-state.entity'; import { SchoolYearEntity } from './schoolyear.entity'; -import { SystemEntity } from './system.entity'; export interface SchoolProperties { _id?: string; diff --git a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts index bd4f3cf19ba..1b50b43221c 100644 --- a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts +++ b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts @@ -1,6 +1,6 @@ import { Entity, ManyToOne, OneToOne, Property } from '@mikro-orm/core'; +import { SystemEntity } from '@modules/system/entity/system.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; -import { SystemEntity } from '@shared/domain/entity/system.entity'; import { BaseEntityWithTimestamps } from './base.entity'; export type IUserLoginMigration = Readonly>; diff --git a/apps/server/src/shared/domain/entity/user.entity.ts b/apps/server/src/shared/domain/entity/user.entity.ts index a253766e29a..cfb27773c5b 100644 --- a/apps/server/src/shared/domain/entity/user.entity.ts +++ b/apps/server/src/shared/domain/entity/user.entity.ts @@ -51,7 +51,6 @@ interface UserInfo { export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property() @Index() - // @Unique() email: string; @Property() diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index de8af17b788..1c4c7b90f97 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -21,7 +21,6 @@ export * from './school'; export * from './schoolexternaltool'; export * from './scope'; export * from './submission'; -export * from './system'; export * from './task'; export * from './teams'; export * from './user'; 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 6bf0f502a04..f3d0ea619a3 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 @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityData, EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain/domainobject'; @@ -10,7 +11,6 @@ import { SchoolRolePermission, SchoolRoles, SchoolYearEntity, - SystemEntity, UserLoginMigrationEntity, } from '@shared/domain/entity'; import { diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.ts b/apps/server/src/shared/repo/school/legacy-school.repo.ts index e61ec482f99..594f0a82ef8 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.ts @@ -1,8 +1,9 @@ import { EntityData, EntityName } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { SchoolEntity, SystemEntity, UserLoginMigrationEntity } from '@shared/domain/entity'; +import { SchoolEntity, UserLoginMigrationEntity } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; import { BaseDORepo } from '../base.do.repo'; diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts index 85954491420..7502ecfdce5 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.spec.ts @@ -6,11 +6,7 @@ import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; -import { - schoolExternalToolConfigurationStatusEntityFactory, - schoolExternalToolEntityFactory, - schoolExternalToolFactory, -} from '@modules/tool/school-external-tool/testing'; +import { schoolExternalToolEntityFactory, schoolExternalToolFactory } from '@modules/tool/school-external-tool/testing'; import { SchoolExternalToolQuery } from '@modules/tool/school-external-tool/uc/dto/school-external-tool.types'; import { Test, TestingModule } from '@nestjs/testing'; import { type SchoolEntity } from '@shared/domain/entity'; @@ -55,15 +51,13 @@ describe(SchoolExternalToolRepo.name, () => { const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school, - status: schoolExternalToolConfigurationStatusEntityFactory.build({ isDeactivated: false }), - }); - const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ - status: undefined, + isDeactivated: false, }); + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); const schoolExternalTool3: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school, - status: schoolExternalToolConfigurationStatusEntityFactory.build({ isDeactivated: true }), + isDeactivated: true, }); return { externalToolEntity, school, schoolExternalTool1, schoolExternalTool2, schoolExternalTool3 }; diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts index 39f9ce92c82..42aa69db1ca 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts @@ -104,7 +104,7 @@ export class SchoolExternalToolRepo { toolId: entity.tool.id, schoolId: entity.school.id, parameters: ExternalToolRepoMapper.mapCustomParameterEntryEntitiesToDOs(entity.schoolParameters), - status: entity.status, + isDeactivated: entity.isDeactivated, }); } @@ -114,7 +114,7 @@ export class SchoolExternalToolRepo { school: this.em.getReference(SchoolEntity, entityDO.schoolId), tool: this.em.getReference(ExternalToolEntity, entityDO.toolId), schoolParameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(entityDO.parameters), - status: entityDO.status, + isDeactivated: entityDO.isDeactivated, }; } } diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.spec.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.spec.ts index 42efc0de412..558d8b68ba3 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.spec.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.spec.ts @@ -51,6 +51,7 @@ describe('SchoolExternalToolScope', () => { describe('when isDeactivated parameter is undefined', () => { it('should return scope without added status to query', () => { scope.byIsDeactivated(undefined); + expect(scope.query).toEqual({}); }); }); @@ -58,14 +59,16 @@ describe('SchoolExternalToolScope', () => { describe('when isDeactivated parameter is false', () => { it('should return scope with added status to query', () => { scope.byIsDeactivated(false); - expect(scope.query).toEqual({ $or: [{ status: { isDeactivated: false } }, { status: undefined }] }); + + expect(scope.query).toEqual({ isDeactivated: false }); }); }); describe('when isDeactivated parameter is true', () => { it('should return scope with added status to query', () => { scope.byIsDeactivated(true); - expect(scope.query).toEqual({ status: { isDeactivated: true } }); + + expect(scope.query).toEqual({ isDeactivated: true }); }); }); }); diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts index 1e5d120d674..1ab1dc0d8b5 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.scope.ts @@ -1,4 +1,4 @@ -import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import type { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { EntityId } from '@shared/domain/types'; import { Scope } from '@shared/repo/scope'; @@ -18,10 +18,8 @@ export class SchoolExternalToolScope extends Scope { } byIsDeactivated(isDeactivated?: boolean): this { - if (isDeactivated) { - this.addQuery({ status: { isDeactivated } }); - } else if (isDeactivated === false) { - this.addQuery({ $or: [{ status: { isDeactivated } }, { status: undefined }] }); + if (isDeactivated !== undefined) { + this.addQuery({ isDeactivated }); } return this; } diff --git a/apps/server/src/shared/repo/scope.ts b/apps/server/src/shared/repo/scope.ts index b66e7c31309..5dd6678cf89 100644 --- a/apps/server/src/shared/repo/scope.ts +++ b/apps/server/src/shared/repo/scope.ts @@ -1,5 +1,5 @@ import { FilterQuery } from '@mikro-orm/core'; -import { EmptyResultQuery } from './query/empty-result.query'; +import { EmptyResultQuery } from './query'; type EmptyResultQueryType = typeof EmptyResultQuery; @@ -32,8 +32,9 @@ export class Scope { this._queries.push(query); } - allowEmptyQuery(isEmptyQueryAllowed: boolean): Scope { + allowEmptyQuery(isEmptyQueryAllowed: boolean): this { this._allowEmptyQuery = isEmptyQueryAllowed; + return this; } } diff --git a/apps/server/src/shared/repo/system/index.ts b/apps/server/src/shared/repo/system/index.ts deleted file mode 100644 index 2c071b949c9..00000000000 --- a/apps/server/src/shared/repo/system/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './legacy-system.repo'; diff --git a/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts deleted file mode 100644 index b8cc14a7311..00000000000 --- a/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 } from '@shared/domain/entity'; -import { SystemTypeEnum } from '@shared/domain/types'; -import { LegacySystemRepo } from '@shared/repo'; -import { systemEntityFactory } from '@shared/testing'; - -describe('system repo', () => { - let module: TestingModule; - let repo: LegacySystemRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [LegacySystemRepo], - }).compile(); - repo = module.get(LegacySystemRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - it('should be defined', () => { - expect(repo).toBeDefined(); - expect(typeof repo.findById).toEqual('function'); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(SystemEntity); - }); - - describe('findById', () => { - afterEach(async () => { - await em.nativeDelete(SystemEntity, {}); - }); - - it('should return a System that matched by id', async () => { - const system = systemEntityFactory.build(); - await em.persistAndFlush([system]); - const result = await repo.findById(system.id); - expect(result).toEqual(system); - }); - - it('should throw an error if System by id doesnt exist', async () => { - const idA = new ObjectId().toHexString(); - - await expect(repo.findById(idA)).rejects.toThrow(NotFoundError); - }); - }); - - describe('findAll', () => { - afterEach(async () => { - await em.nativeDelete(SystemEntity, {}); - }); - - it('should return all systems', async () => { - const systems = [systemEntityFactory.build(), systemEntityFactory.build({ oauthConfig: undefined })]; - await em.persistAndFlush(systems); - - const result = await repo.findAll(); - - expect(result.length).toEqual(systems.length); - expect(result).toEqual(systems); - }); - }); - - describe('findByFilter', () => { - 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]); - }); - - afterAll(async () => { - await em.nativeDelete(SystemEntity, {}); - }); - - describe('when searching for a system type', () => { - it('should return ldap systems', async () => { - const result = await repo.findByFilter(SystemTypeEnum.LDAP); - expect(result).toStrictEqual(ldapSystems); - }); - - it('should return oauth systems', async () => { - const result = await repo.findByFilter(SystemTypeEnum.OAUTH); - expect(result).toStrictEqual(oauthSystems); - }); - - it('should return oidc systems', async () => { - const result = await repo.findByFilter(SystemTypeEnum.OIDC); - expect(result).toStrictEqual(oidcSystems); - }); - }); - - describe('when system type is unknown', () => { - it('should throw', async () => { - await expect(repo.findByFilter('keycloak' as unknown as SystemTypeEnum)).rejects.toThrow( - 'system type keycloak unknown' - ); - }); - }); - }); -}); diff --git a/apps/server/src/shared/repo/system/legacy-system.repo.ts b/apps/server/src/shared/repo/system/legacy-system.repo.ts deleted file mode 100644 index f5fa9324bf2..00000000000 --- a/apps/server/src/shared/repo/system/legacy-system.repo.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { SystemEntity } from '@shared/domain/entity'; -import { SystemTypeEnum } from '@shared/domain/types'; -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 LegacySystemRepo extends BaseRepo { - get entityName() { - return SystemEntity; - } - - async findByFilter(type: SystemTypeEnum): Promise { - const scope = new SystemScope(); - switch (type) { - case SystemTypeEnum.LDAP: - scope.withLdapConfig(); - break; - case SystemTypeEnum.OAUTH: - scope.withOauthConfig(); - break; - case SystemTypeEnum.OIDC: - scope.withOidcConfig(); - break; - default: - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`system type ${type} unknown`); - } - return this._em.find(SystemEntity, scope.query); - } - - async findAll(): Promise { - return this._em.find(SystemEntity, {}); - } -} diff --git a/apps/server/src/shared/repo/system/system-scope.ts b/apps/server/src/shared/repo/system/system-scope.ts deleted file mode 100644 index 77819f5afa4..00000000000 --- a/apps/server/src/shared/repo/system/system-scope.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { SystemEntity } from '@shared/domain/entity'; -import { Scope } from '../scope'; - -export class SystemScope extends Scope { - withLdapConfig(): SystemScope { - this.addQuery({ ldapConfig: { $ne: null } }); - return this; - } - - withOauthConfig(): SystemScope { - this.addQuery({ oauthConfig: { $ne: null } }); - return this; - } - - withOidcConfig(): SystemScope { - this.addQuery({ oidcConfig: { $ne: null } }); - return this; - } -} 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 532a394f6f0..46add47b8e5 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 @@ -3,12 +3,13 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityData, FindOptions, NotFoundError, QueryOrderMap } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { MultipleUsersFoundLoggableException } from '@modules/oauth/loggable'; +import { SystemEntity } from '@modules/system/entity'; import { UserQuery } from '@modules/user/service/user-query.type'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { Page } from '@shared/domain/domainobject/page'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { Role, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Role, SchoolEntity, User } from '@shared/domain/entity'; import { IFindOptions, LanguageType, RoleName, SortOrder } from '@shared/domain/interface'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { 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 51834f98050..d499efc45e8 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 @@ -1,8 +1,9 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { type SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; -import { SystemEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { SortOrder } from '@shared/domain/interface'; import { 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 a762d3eeab2..993f72aedb4 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,14 +1,15 @@ import { createMock } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; +import { type SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { cleanupCollections, schoolEntityFactory, systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { userLoginMigrationFactory } from '../../testing/factory/user-login-migration.factory'; +import { userLoginMigrationFactory } from '../../testing'; import { UserLoginMigrationRepo } from './user-login-migration.repo'; describe('UserLoginMigrationRepo', () => { diff --git a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.ts b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.ts index 5a35295ac31..4c8598bdc61 100644 --- a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.ts +++ b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.ts @@ -1,8 +1,9 @@ import { EntityData, EntityName } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; import { Injectable } from '@nestjs/common'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 19de632fba1..806f417ce84 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -5,5 +5,10 @@ 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'; +export { + systemFactory, + systemOauthConfigFactory, + systemLdapConfigFactory, + systemOidcConfigFactory, +} from './system/system.factory'; export { schoolSystemOptionsFactory } from './school-system-options/school-system-options.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 index 35e1c3f438c..761345861c4 100644 --- a/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts @@ -1,10 +1,81 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { System, SystemProps } from '@modules/system/domain'; +import { LdapConfig, OauthConfig, OidcConfig, System, SystemProps, SystemType } from '@modules/system/domain'; +import { DeepPartial, Factory } from 'fishery'; import { DomainObjectFactory } from '../domain-object.factory'; -export const systemFactory = DomainObjectFactory.define(System, () => { +export const systemOauthConfigFactory = Factory.define( + () => + new OauthConfig({ + clientId: '12345', + clientSecret: 'mocksecret', + idpHint: 'mock-oauth-idpHint', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', + grantType: 'authorization_code', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', + scope: 'openid uuid', + responseType: 'code', + authEndpoint: 'https://mock.de/auth', + provider: 'mock_type', + logoutEndpoint: 'https://mock.de/logout', + issuer: 'mock_issuer', + jwksEndpoint: 'https://mock.de/jwks', + }) +); + +export const systemLdapConfigFactory = Factory.define( + () => + new LdapConfig({ + active: true, + url: 'ldaps://test.ldap/', + }) +); + +export const systemOidcConfigFactory = Factory.define( + () => + new OidcConfig({ + clientId: 'mock-client-id', + clientSecret: 'mock-client-secret', + idpHint: 'mock-oidc-idpHint', + defaultScopes: 'openid email userinfo', + authorizationUrl: 'https://mock.tld/auth', + tokenUrl: 'https://mock.tld/token', + userinfoUrl: 'https://mock.tld/userinfo', + logoutUrl: 'https://mock.tld/logout', + }) +); + +class SystemFactory extends DomainObjectFactory { + withOauthConfig(params?: DeepPartial): this { + const oauthConfig: OauthConfig = systemOauthConfigFactory.build(params); + + return this.params({ + type: SystemType.OAUTH, + oauthConfig, + }); + } + + withLdapConfig(params?: DeepPartial): this { + const ldapConfig: LdapConfig = systemLdapConfigFactory.build(params); + + return this.params({ + type: SystemType.LDAP, + ldapConfig, + }); + } + + withOidcConfig(params?: DeepPartial): this { + const oidcConfig: OidcConfig = systemOidcConfigFactory.build(params); + + return this.params({ + type: SystemType.OIDC, + oidcConfig, + }); + } +} + +export const systemFactory = SystemFactory.define(System, () => { return { id: new ObjectId().toHexString(), - type: 'oauth2', + type: SystemType.OAUTH, }; }); diff --git a/apps/server/src/shared/testing/factory/systemEntityFactory.ts b/apps/server/src/shared/testing/factory/systemEntityFactory.ts index aca7c9e05c9..f575ee2168b 100644 --- a/apps/server/src/shared/testing/factory/systemEntityFactory.ts +++ b/apps/server/src/shared/testing/factory/systemEntityFactory.ts @@ -4,7 +4,7 @@ import { OidcConfigEntity, SystemEntity, SystemEntityProps, -} from '@shared/domain/entity'; +} from '@modules/system/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SystemTypeEnum } from '@shared/domain/types'; import { DeepPartial } from 'fishery'; diff --git a/apps/server/test/globalSetup.ts b/apps/server/test/globalSetup.ts index 68b50329bb9..0928ed73cf9 100644 --- a/apps/server/test/globalSetup.ts +++ b/apps/server/test/globalSetup.ts @@ -3,7 +3,7 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; export = async function globalSetup() { const instance = await MongoMemoryServer.create({ binary: { - version: '5.0.26', + version: '6.0.16', }, }); const uri = instance.getUri(); diff --git a/backup/setup/classes.json b/backup/setup/classes.json index b53a41a6ff6..c1aecf2afb7 100644 --- a/backup/setup/classes.json +++ b/backup/setup/classes.json @@ -150,6 +150,36 @@ "$date": "2023-07-31T10:01:29.382Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "668c0a7b46c42b79f6034ff0" + }, + "userIds": [ + { + "$oid": "5fa2cccab229544f2c696917" + } + ], + "teacherIds": [ + { + "$oid": "5fa2c71bb229544f2c6966d9" + } + ], + "schoolId": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "name": "b", + "gradeLevel": 9, + "year": { + "$oid": "5ebd6dc14a431f75ec9a3e7a" + }, + "createdAt": { + "$date": "2024-07-08T10:00:26.985Z" + }, + "updatedAt": { + "$date": "2024-07-08T10:01:29.382Z" + }, + "__v": 0 }, { "_id": { diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 7130b67a48b..f9e53c37498 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -113,6 +113,33 @@ "isDeactivated": false, "restrictToContexts": [] }, + { + "_id": { + "$oid": "667e4fe648ea6a22a5474359" + }, + "createdAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "name": "CY Test Tool Course Restriction", + "url": "https://google.de/", + "config_type": "basic", + "config_baseUrl": "https://google.de/", + "parameters": [], + "isHidden": false, + "openNewTab": true, + "version": 1, + "isDeactivated": false, + "restrictToContexts": [ + "course" + ] + }, { "_id": { "$oid": "644a4593d0a8301e6cf25d86" @@ -140,6 +167,60 @@ "board-element" ] }, + { + "_id": { + "$oid": "667e50f6162707ce02b9ac02" + }, + "createdAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "name": "CY Test Tool Media-Board Restriction", + "url": "https://google.de/", + "config_type": "basic", + "config_baseUrl": "https://google.de/", + "parameters": [], + "isHidden": false, + "openNewTab": true, + "version": 1, + "isDeactivated": false, + "restrictToContexts": [ + "media-board" + ] + }, + { + "_id": { + "$oid": "667e52a4162707ce02b9ac04" + }, + "createdAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1682589075592" + } + }, + "name": "CY Test Tool All Restrictions", + "url": "https://google.de/", + "config_type": "basic", + "config_baseUrl": "https://google.de/", + "parameters": [], + "isHidden": false, + "openNewTab": true, + "version": 1, + "isDeactivated": false, + "restrictToContexts": [ + "course","board-element","media-board" + ] + }, { "_id": { "$oid": "647de247cf6a427b9d39e5b1" diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index be63aa0fe27..dbba0355eee 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -169,5 +169,14 @@ "created_at": { "$date": "2024-06-11T12:51:38.379Z" } + }, + { + "_id": { + "$oid": "667e611e207a39b02c306406" + }, + "name": "Migration20240627134214", + "created_at": { + "$date": "2024-06-28T07:07:10.278Z" + } } ] diff --git a/backup/setup/schools.json b/backup/setup/schools.json index 4681123d100..77b09a9085e 100644 --- a/backup/setup/schools.json +++ b/backup/setup/schools.json @@ -25,7 +25,7 @@ "__v": 0, "fileStorageType": "awsS3", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "permissions": { @@ -53,7 +53,7 @@ }, "__v": 0, "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "permissions": { @@ -89,7 +89,7 @@ }, "__v": 0, "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "features": [ @@ -131,7 +131,7 @@ "__v": 0, "fileStorageType": "awsS3", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "expert", "features" : [ @@ -167,7 +167,7 @@ }, "fileStorageType": "awsS3", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "createdAt": { "$date": "2020-11-04T15:16:28.827Z" @@ -216,7 +216,7 @@ }, "fileStorageType": "awsS3", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "createdAt": { "$date": "2020-11-04T21:11:14.312Z" @@ -258,7 +258,7 @@ "fileStorageType": "awsS3", "timezone": "America/Belem", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "createdAt": { "$date": "2020-11-04T21:38:05.110Z" @@ -303,7 +303,7 @@ }, "fileStorageType": "awsS3", "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "createdAt": { "$date": "2020-12-08T16:58:36.527Z" @@ -370,7 +370,7 @@ }, "__v": 0, "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "features": [ @@ -412,7 +412,7 @@ }, "__v": 0, "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "features": [ @@ -454,7 +454,7 @@ }, "__v": 0, "currentYear": { - "$oid": "5ebd6dc14a431f75ec9a3e79" + "$oid": "5ebd6dc14a431f75ec9a3e7a" }, "purpose": "demo", "features": [ diff --git a/config/default.schema.json b/config/default.schema.json index 743bfe41711..d53b95a6936 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -434,12 +434,25 @@ "IDENTITY_MANAGEMENT": { "type": "object", "description": "Identity management server properties.", - "required": ["URI", "TENANT", "CLIENTID", "ADMIN_CLIENTID", "ADMIN_USER", "ADMIN_PASSWORD"], + "required": [ + "INTERNAL_URI", + "EXTERNAL_URI", + "TENANT", + "CLIENTID", + "ADMIN_CLIENTID", + "ADMIN_USER", + "ADMIN_PASSWORD" + ], "properties": { - "URI": { + "INTERNAL_URI": { "type": "string", "default": null, - "description": "The ErWIn IDM base URI." + "description": "The ErWIn IDM base URI for Kubernetes cluster internal use." + }, + "EXTERNAL_URI": { + "type": "string", + "default": null, + "description": "The ErWIn IDM base URI for Kubernetes cluster external use." }, "TENANT": { "type": "string", @@ -1120,7 +1133,7 @@ }, "FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED": { "type": "boolean", - "default": false, + "default": true, "description": "Enable collaborative text editor in column board." }, "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": { @@ -1577,6 +1590,17 @@ "type": "string", "description": "List with allowed assets MIME types, comma separated, empty if all MIME types supported by tldraw should be allowed", "examples": ["image/gif,image/jpeg,video/webm"] + }, + "PERFORMANCE_MEASURE_ENABLED": { + "type": "boolean", + "description": "Activate the performance measure for observed areas.", + "default": true + }, + "LOG_LEVEL": { + "type": "string", + "default": "info", + "description": "Define log level for tldraw.", + "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] } }, "default": { @@ -1689,7 +1713,7 @@ }, "FEATURE_NEW_LAYOUT_ENABLED": { "type": "boolean", - "default": false, + "default": true, "description": "Enables the new layout feature" } }, diff --git a/config/development.json b/config/development.json index bf191822f66..6fe9ff82185 100644 --- a/config/development.json +++ b/config/development.json @@ -40,7 +40,8 @@ }, "FEATURE_IDENTITY_MANAGEMENT_ENABLED": true, "IDENTITY_MANAGEMENT": { - "URI": "http://localhost:8080", + "INTERNAL_URI": "http://localhost:8080", + "EXTERNAL_URI": "http://localhost:8080", "TENANT": "dBildungscloud", "CLIENTID": "dbc", "ADMIN_CLIENTID": "admin-cli", @@ -92,5 +93,8 @@ "URI": "http://localhost:9001/api/1.2.14" }, "PROVISIONING_SCHULCONNEX_LIZENZ_INFO_URL": "http://localhost:8888/v1/lizenzinfo", - "BOARD_COLLABORATION_URI": "ws://localhost:4450" + "BOARD_COLLABORATION_URI": "ws://localhost:4450", + "ADMIN_API": { + "ALLOWED_API_KEYS": "thisisasupersecureapikeythatisabsolutelysave" + } } diff --git a/config/test.json b/config/test.json index d69ba6fc8f7..3789f2633c5 100644 --- a/config/test.json +++ b/config/test.json @@ -33,7 +33,8 @@ }, "FEATURE_IDENTITY_MANAGEMENT_ENABLED": true, "IDENTITY_MANAGEMENT": { - "URI": "http://localhost:8080", + "INTERNAL_URI": "http://localhost:8080", + "EXTERNAL_URI": "http://localhost:8080", "TENANT": "master", "CLIENTID": "dbc", "ADMIN_CLIENTID": "admin-cli", diff --git a/package-lock.json b/package-lock.json index d593c3a34ec..b57695263af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@golevelup/nestjs-rabbitmq": "^4.0.0", "@hendt/xml2json": "^1.0.3", "@hpi-schul-cloud/commons": "^1.3.4", - "@keycloak/keycloak-admin-client": "^23.0.6", + "@keycloak/keycloak-admin-client": "^25.0.1", "@lumieducation/h5p-server": "^9.2.0", "@mikro-orm/cli": "^5.6.16", "@mikro-orm/core": "^5.6.16", @@ -118,7 +118,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfmake": "^0.2.9", - "prom-client": "^13.1.0", + "prom-client": "^15.1.3", "qs": "^6.9.7", "read-chunk": "^3.0.0", "reflect-metadata": "^0.1.13", @@ -1717,10 +1717,11 @@ } }, "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.49.0", - "license": "Apache-2.0", + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", "dependencies": { - "tslib": "^2.3.0" + "tslib": "^2.3.1" } }, "node_modules/@aws-sdk/util-waiter": { @@ -2479,6 +2480,14 @@ "npm": ">=6.14.13" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, "node_modules/@feathersjs/adapter-commons": { "version": "5.0.12", "license": "MIT", @@ -2937,8 +2946,9 @@ "license": "MIT" }, "node_modules/@httptoolkit/websocket-stream": { - "version": "6.0.0", - "license": "BSD-2-Clause", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@httptoolkit/websocket-stream/-/websocket-stream-6.0.1.tgz", + "integrity": "sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==", "optional": true, "peer": true, "dependencies": { @@ -2953,8 +2963,9 @@ } }, "node_modules/@httptoolkit/websocket-stream/node_modules/readable-stream": { - "version": "2.3.7", - "license": "MIT", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "optional": true, "peer": true, "dependencies": { @@ -2969,13 +2980,15 @@ }, "node_modules/@httptoolkit/websocket-stream/node_modules/safe-buffer": { "version": "5.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "optional": true, "peer": true }, "node_modules/@httptoolkit/websocket-stream/node_modules/string_decoder": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "optional": true, "peer": true, "dependencies": { @@ -3882,13 +3895,13 @@ "license": "MIT" }, "node_modules/@keycloak/keycloak-admin-client": { - "version": "23.0.6", - "license": "Apache-2.0", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-25.0.1.tgz", + "integrity": "sha512-FzJ7OSa6MvBfJbiH/+vlH6Kjz9b74z5eVMPWm4kZmUg1+M4bH3oeSsC2y1Yis72Yrtt62PmlaE2ddaaz+iGi/Q==", "dependencies": { "camelize-ts": "^3.0.0", - "lodash-es": "^4.17.21", "url-join": "^5.0.0", - "url-template": "^3.1.0" + "url-template": "^3.1.1" }, "engines": { "node": ">=18" @@ -3910,6 +3923,8 @@ }, "node_modules/@lumieducation/h5p-server": { "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@lumieducation/h5p-server/-/h5p-server-9.2.0.tgz", + "integrity": "sha512-npW5hXyFikFS7LakT6O+4FQgJNHEAyEMRm9VTifyZcNuQ+lMWoz2gGbEuoT4PcTyaK+a1f6G8V8G3882fL0qKQ==", "license": "GPL-3.0-or-later", "dependencies": { "ajv": "^8.11.0", @@ -3940,6 +3955,8 @@ }, "node_modules/@lumieducation/h5p-server/node_modules/axios": { "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", "license": "MIT", "dependencies": { "follow-redirects": "^1.14.9", @@ -3948,6 +3965,8 @@ }, "node_modules/@lumieducation/h5p-server/node_modules/cache-manager": { "version": "3.6.3", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", + "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", "license": "MIT", "dependencies": { "async": "3.2.3", @@ -3970,6 +3989,20 @@ } } }, + "node_modules/@lumieducation/h5p-server/node_modules/qs": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.3.tgz", + "integrity": "sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@mikro-orm/cli": { "version": "5.6.16", "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-5.6.16.tgz", @@ -4865,14 +4898,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.2.4", - "license": "MIT", + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", + "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", - "express": "4.18.2", + "express": "4.19.2", "multer": "1.4.4-lts.1", - "tslib": "2.6.2" + "tslib": "2.6.3" }, "funding": { "type": "opencollective", @@ -4883,6 +4917,11 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + }, "node_modules/@nestjs/platform-socket.io": { "version": "10.3.7", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.7.tgz", @@ -4902,11 +4941,12 @@ } }, "node_modules/@nestjs/platform-ws": { - "version": "10.3.1", - "license": "MIT", + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.3.10.tgz", + "integrity": "sha512-xHiMu162ycuiYJFuIlemCV6CK93Q8eh0Ljvq3sGZ+Oin1Xw7wA67NMADnaEr8Uv/LCUyo813uHNIeQaxL8GkRw==", "dependencies": { - "tslib": "2.6.2", - "ws": "8.16.0" + "tslib": "2.6.3", + "ws": "8.17.1" }, "funding": { "type": "opencollective", @@ -4918,25 +4958,10 @@ "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/platform-ws/node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "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/platform-ws/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/@nestjs/schematics": { "version": "10.0.2", @@ -5418,6 +5443,14 @@ "node": ">=8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@panva/asn1.js": { "version": "1.0.0", "license": "MIT", @@ -5491,23 +5524,117 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@rushstack/node-core-library": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.5.0.tgz", + "integrity": "sha512-Cl3MYQ74Je5Y/EngMxcA3SpHjGZ/022nKbAO1aycGfQ+7eKyNCBu0oywj5B1f367GCzuHBgy+3BlVLKysHkXZw==", + "dependencies": { + "ajv": "~8.13.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/ajv": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", + "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.13.1.tgz", + "integrity": "sha512-RfJcpEYfCzEM/8dgRm4xVs8g4x+AdGdZZGa+XmZRWEKbKkVJSHxKmoe5z0f8gFNip0bnlxNavB9cxNaTSY/JRQ==", + "dependencies": { + "@rushstack/node-core-library": "5.5.0", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@rushstack/ts-command-line": { - "version": "4.17.1", - "license": "MIT", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.1.tgz", + "integrity": "sha512-wU/igKNFRPmQvxiRAM9lEx/5xcFRK72zBp+fbykPKIm83bOmVE0WWQ+ZhX/pcJJqQiodcr0DDzOMw4O8SwpMSQ==", "dependencies": { + "@rushstack/terminal": "0.13.1", "@types/argparse": "1.0.38", "argparse": "~1.0.9", - "colors": "~1.2.1", "string-argv": "~0.3.1" } }, - "node_modules/@rushstack/ts-command-line/node_modules/colors": { - "version": "1.2.5", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@servie/events": { "version": "1.0.0", "license": "MIT" @@ -5650,7 +5777,8 @@ }, "node_modules/@types/argparse": { "version": "1.0.38", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==" }, "node_modules/@types/babel__core": { "version": "7.1.18", @@ -5721,20 +5849,12 @@ "license": "MIT" }, "node_modules/@types/clamscan": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "axios": "^0.24.0" - } - }, - "node_modules/@types/clamscan/node_modules/axios": { - "version": "0.24.0", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.8.tgz", + "integrity": "sha512-HaOKUH+MKgGZAYakboOSHcHga1jGRgD4kpUUslceKtsOqDY16yCLHcURETSF7jOokJOR/Z0k2wk0RL+pN0cbUg==", "dev": true, - "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.4" + "@types/node": "*" } }, "node_modules/@types/compression": { @@ -6105,8 +6225,9 @@ } }, "node_modules/@types/tough-cookie": { - "version": "2.3.8", - "license": "MIT" + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" }, "node_modules/@types/uuid": { "version": "8.3.4", @@ -6757,6 +6878,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-formats": { "version": "2.1.1", "license": "MIT", @@ -6819,12 +6953,6 @@ "node": ">=10" } }, - "node_modules/ansi": { - "version": "0.3.1", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/ansi-colors": { "version": "4.1.1", "dev": true, @@ -6902,16 +7030,6 @@ "dev": true, "license": "MIT" }, - "node_modules/are-we-there-yet": { - "version": "1.0.6", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.0 || ^1.1.13" - } - }, "node_modules/arg": { "version": "5.0.1", "license": "MIT" @@ -7255,51 +7373,67 @@ } }, "node_modules/aws-crt": { - "version": "1.10.6", + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/aws-crt/-/aws-crt-1.21.3.tgz", + "integrity": "sha512-oaiP5zoPkXwbM9T3nwSgq6CBZWx0501iefLPg12FODniIgqGMyzbMXHYC+fxbCoP5SOQVmCwtAfbNuIG5bFENg==", "hasInstallScript": true, - "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": { - "@httptoolkit/websocket-stream": "^6.0.0", - "axios": "^0.24.0", - "cmake-js": "6.3.0", - "crypto-js": "^4.0.0", - "fastestsmallesttextencoderdecoder": "^1.0.22", - "mqtt": "^4.3.4", - "tar": "^6.1.11", - "ws": "^7.5.5" + "@aws-sdk/util-utf8-browser": "^3.109.0", + "@httptoolkit/websocket-stream": "^6.0.1", + "axios": "^1.6.8", + "buffer": "^6.0.3", + "crypto-js": "^4.2.0", + "mqtt": "^4.3.8", + "process": "^0.11.10" } }, - "node_modules/aws-crt/node_modules/axios": { - "version": "0.24.0", - "license": "MIT", + "node_modules/aws-crt/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "optional": true, "peer": true, "dependencies": { - "follow-redirects": "^1.14.4" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/aws-crt/node_modules/ws": { - "version": "7.5.9", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true + "node_modules/aws-crt/node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "utf-8-validate": { - "optional": true + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } - } + ], + "optional": true, + "peer": true }, "node_modules/aws-sdk": { "version": "2.1375.0", @@ -7521,25 +7655,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/big-integer": { - "version": "1.6.51", - "license": "Unlicense", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/binary": { - "version": "0.3.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "dev": true, @@ -7618,20 +7733,6 @@ "node": ">= 0.8" } }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "license": "MIT" @@ -7659,13 +7760,6 @@ "node": ">= 0.8" } }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/boolbase": { "version": "1.0.0", "license": "ISC" @@ -7683,10 +7777,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "license": "MIT", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -7843,33 +7938,10 @@ "version": "1.1.2", "license": "MIT" }, - "node_modules/buffer-indexof-polyfill": { - "version": "1.0.2", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10" - } - }, "node_modules/buffer-more-ints": { "version": "1.0.0", "license": "MIT" }, - "node_modules/buffer-shims": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/buffers": { - "version": "0.1.1", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/build-url": { "version": "1.3.3", "license": "MIT" @@ -7980,12 +8052,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "license": "MIT", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8084,21 +8162,6 @@ "@types/node": "*" } }, - "node_modules/chainsaw": { - "version": "0.1.0", - "license": "MIT/X11", - "optional": true, - "peer": true, - "dependencies": { - "traverse": ">=0.3.0 <0.4" - } - }, - "node_modules/chainsaw/node_modules/traverse": { - "version": "0.3.9", - "license": "MIT/X11", - "optional": true, - "peer": true - }, "node_modules/chalk": { "version": "5.0.0", "license": "MIT", @@ -8308,15 +8371,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "2.0.0", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.3", "dev": true, @@ -8418,77 +8472,6 @@ "node": ">=4.2.0" } }, - "node_modules/cliui": { - "version": "3.2.0", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "2.1.1", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "1.0.2", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "3.0.1", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "2.1.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/clone": { "version": "1.0.4", "license": "MIT", @@ -8503,152 +8486,6 @@ "node": ">=0.10.0" } }, - "node_modules/cmake-js": { - "version": "6.3.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "axios": "^0.21.1", - "debug": "^4", - "fs-extra": "^5.0.0", - "is-iojs": "^1.0.1", - "lodash": "^4", - "memory-stream": "0", - "npmlog": "^1.2.0", - "rc": "^1.2.7", - "semver": "^5.0.3", - "splitargs": "0", - "tar": "^4", - "unzipper": "^0.8.13", - "url-join": "0", - "which": "^1.0.9", - "yargs": "^3.6.0" - }, - "bin": { - "cmake-js": "bin/cmake-js" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/cmake-js/node_modules/axios": { - "version": "0.21.4", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "follow-redirects": "^1.14.0" - } - }, - "node_modules/cmake-js/node_modules/chownr": { - "version": "1.1.4", - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/cmake-js/node_modules/fs-extra": { - "version": "5.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "node_modules/cmake-js/node_modules/fs-minipass": { - "version": "1.2.7", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^2.6.0" - } - }, - "node_modules/cmake-js/node_modules/jsonfile": { - "version": "4.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/cmake-js/node_modules/minipass": { - "version": "2.9.0", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - }, - "node_modules/cmake-js/node_modules/minizlib": { - "version": "1.3.3", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^2.9.0" - } - }, - "node_modules/cmake-js/node_modules/mkdirp": { - "version": "0.5.5", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/cmake-js/node_modules/semver": { - "version": "5.7.2", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/cmake-js/node_modules/tar": { - "version": "4.4.19", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "chownr": "^1.1.4", - "fs-minipass": "^1.2.7", - "minipass": "^2.9.0", - "minizlib": "^1.3.3", - "mkdirp": "^0.5.5", - "safe-buffer": "^5.2.1", - "yallist": "^3.1.1" - }, - "engines": { - "node": ">=4.5" - } - }, - "node_modules/cmake-js/node_modules/universalify": { - "version": "0.1.2", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/cmake-js/node_modules/yallist": { - "version": "3.1.1", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/co": { "version": "4.6.0", "dev": true, @@ -8658,15 +8495,6 @@ "node": ">= 0.12.0" } }, - "node_modules/code-point-at": { - "version": "1.1.0", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.1", "dev": true, @@ -9044,8 +8872,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.5.0", - "license": "MIT", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -9437,15 +9266,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "license": "MIT" @@ -9477,15 +9297,19 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", - "license": "MIT", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -9518,12 +9342,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/depd": { "version": "1.1.2", "license": "MIT", @@ -9724,7 +9542,8 @@ }, "node_modules/duplexify": { "version": "3.7.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", "optional": true, "peer": true, "dependencies": { @@ -9735,8 +9554,9 @@ } }, "node_modules/duplexify/node_modules/readable-stream": { - "version": "2.3.7", - "license": "MIT", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "optional": true, "peer": true, "dependencies": { @@ -9751,13 +9571,15 @@ }, "node_modules/duplexify/node_modules/safe-buffer": { "version": "5.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "optional": true, "peer": true }, "node_modules/duplexify/node_modules/string_decoder": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "optional": true, "peer": true, "dependencies": { @@ -9833,9 +9655,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", - "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -9846,46 +9668,25 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" } }, "node_modules/engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", + "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", "dev": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", + "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "dev": true, - "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/engine.io-parser": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", @@ -9902,26 +9703,6 @@ "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "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/enhanced-resolve": { "version": "5.15.0", "dev": true, @@ -10011,6 +9792,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.3.0", "dev": true, @@ -10052,12 +9852,14 @@ } }, "node_modules/es5-ext": { - "version": "0.10.62", + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "hasInstallScript": true, - "license": "ISC", "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" }, "engines": { @@ -11002,6 +10804,25 @@ "node": ">=6" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esniff/node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" + }, "node_modules/espree": { "version": "9.4.1", "dev": true, @@ -11159,15 +10980,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "license": "MIT", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -11343,28 +11165,6 @@ "version": "2.0.0", "license": "MIT" }, - "node_modules/express/node_modules/body-parser": { - "version": "1.20.1", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "license": "MIT", @@ -11374,7 +11174,8 @@ }, "node_modules/express/node_modules/depd": { "version": "2.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { "node": ">= 0.8" } @@ -11395,20 +11196,6 @@ "node": ">= 0.8" } }, - "node_modules/express/node_modules/http-errors": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "license": "MIT" @@ -11427,13 +11214,6 @@ "version": "0.1.7", "license": "MIT" }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/ext": { "version": "1.7.0", "license": "ISC", @@ -11530,12 +11310,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "license": "CC0-1.0", - "optional": true, - "peer": true - }, "node_modules/fastq": { "version": "1.13.0", "license": "ISC", @@ -11694,8 +11468,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "license": "MIT", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -11964,7 +11739,8 @@ }, "node_modules/fs-jetpack": { "version": "4.3.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-4.3.1.tgz", + "integrity": "sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==", "dependencies": { "minimatch": "^3.0.2", "rimraf": "^2.6.3" @@ -11972,7 +11748,9 @@ }, "node_modules/fs-jetpack/node_modules/rimraf": { "version": "2.7.1", - "license": "ISC", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dependencies": { "glob": "^7.1.3" }, @@ -11980,18 +11758,6 @@ "rimraf": "bin.js" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/fs-monkey": { "version": "1.0.3", "dev": true, @@ -12001,43 +11767,18 @@ "version": "1.0.0", "license": "ISC" }, - "node_modules/fstream": { - "version": "1.0.12", - "license": "ISC", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.6" - } - }, - "node_modules/fstream/node_modules/mkdirp": { - "version": "0.5.5", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { @@ -12075,19 +11816,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "1.2.7", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "ansi": "^0.3.0", - "has-unicode": "^2.0.0", - "lodash.pad": "^4.1.0", - "lodash.padend": "^4.1.0", - "lodash.padstart": "^4.1.0" - } - }, "node_modules/generic-pool": { "version": "3.9.0", "license": "MIT", @@ -12122,22 +11850,28 @@ } }, "node_modules/get-func-name": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, - "license": "MIT", "engines": { "node": "*" } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "license": "MIT", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12438,10 +12172,11 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "license": "MIT", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12480,12 +12215,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/hasha": { "version": "5.2.2", "dev": true, @@ -12605,6 +12334,29 @@ "entities": "^2.0.0" } }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "license": "MIT", @@ -12752,6 +12504,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "engines": { + "node": ">=8" + } + }, "node_modules/import-local": { "version": "3.1.0", "dev": true, @@ -12798,12 +12558,6 @@ "version": "2.0.4", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/inquirer": { "version": "7.3.3", "dev": true, @@ -12919,15 +12673,6 @@ "node": ">= 0.10" } }, - "node_modules/invert-kv": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ioredis": { "version": "5.3.2", "license": "MIT", @@ -12972,12 +12717,31 @@ "node": ">=0.10" } }, - "node_modules/ip": { - "version": "2.0.0", - "license": "MIT" + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, "node_modules/ip-regex": { "version": "2.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -13177,12 +12941,6 @@ "node": ">=8" } }, - "node_modules/is-iojs": { - "version": "1.1.0", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-ip": { "version": "2.0.0", "dev": true, @@ -13206,7 +12964,8 @@ }, "node_modules/is-number": { "version": "7.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "engines": { "node": ">=0.12.0" } @@ -13379,7 +13138,8 @@ }, "node_modules/isomorphic-ws": { "version": "4.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", "optional": true, "peer": true, "peerDependencies": { @@ -15358,7 +15118,6 @@ }, "node_modules/jju": { "version": "1.4.0", - "dev": true, "license": "MIT" }, "node_modules/jmespath": { @@ -15706,8 +15465,9 @@ } }, "node_modules/jwks-rsa/node_modules/jose": { - "version": "2.0.6", - "license": "MIT", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", + "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", "dependencies": { "@panva/asn1.js": "^1.0.0" }, @@ -15864,18 +15624,6 @@ "language-subtag-registry": "~0.3.2" } }, - "node_modules/lcid": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "invert-kv": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ldap-filter": { "version": "0.2.2", "license": "MIT", @@ -15978,12 +15726,6 @@ "dev": true, "license": "MIT" }, - "node_modules/listenercount": { - "version": "1.0.1", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/load-tsconfig": { "version": "0.2.3", "dev": true, @@ -16014,10 +15756,6 @@ "version": "4.17.21", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "license": "MIT" - }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "license": "MIT" @@ -16082,29 +15820,6 @@ "version": "4.1.1", "license": "MIT" }, - "node_modules/lodash.pad": { - "version": "4.5.1", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/lodash.padend": { - "version": "4.6.1", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/lodash.padstart": { - "version": "4.6.1", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/lodash.set": { - "version": "4.3.2", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.sortby": { "version": "4.7.0", "dev": true, @@ -16419,33 +16134,6 @@ "license": "MIT", "optional": true }, - "node_modules/memory-stream": { - "version": "0.0.3", - "license": "MMIT", - "optional": true, - "peer": true, - "dependencies": { - "readable-stream": "~1.0.26-2" - } - }, - "node_modules/memory-stream/node_modules/isarray": { - "version": "0.0.1", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/memory-stream/node_modules/readable-stream": { - "version": "1.0.34", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, "node_modules/merge": { "version": "2.1.1", "license": "MIT" @@ -16555,38 +16243,13 @@ "version": "1.2.6", "license": "MIT" }, - "node_modules/minipass": { - "version": "3.1.6", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/mixwith": { "version": "0.1.1", "license": "Apache-2.0" }, "node_modules/mkdirp": { "version": "1.0.4", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -17058,8 +16721,9 @@ } }, "node_modules/mqtt": { - "version": "4.3.5", - "license": "MIT", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.3.8.tgz", + "integrity": "sha512-2xT75uYa0kiPEF/PE0VPdavmEkoBzMT/UL9moid0rAvlCtV48qBwxD62m7Ld/4j8tSkIO1E/iqRl/S72SEOhOw==", "optional": true, "peer": true, "dependencies": { @@ -17195,8 +16859,9 @@ } }, "node_modules/mqtt/node_modules/ws": { - "version": "7.5.9", - "license": "MIT", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "optional": true, "peer": true, "engines": { @@ -17370,14 +17035,15 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.6", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -17507,13 +17173,13 @@ } }, "node_modules/nock": { - "version": "13.2.4", + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.4.tgz", + "integrity": "sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", - "lodash.set": "^4.3.2", "propagate": "^2.0.0" }, "engines": { @@ -17709,17 +17375,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "1.2.1", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "ansi": "~0.3.0", - "are-we-there-yet": "~1.0.0", - "gauge": "~1.2.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "license": "BSD-2-Clause", @@ -17740,15 +17395,6 @@ "js-sdsl": "^2.1.2" } }, - "node_modules/number-is-nan": { - "version": "1.0.1", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nyc": { "version": "15.1.0", "dev": true, @@ -18258,18 +17904,6 @@ "node": ">=8" } }, - "node_modules/os-locale": { - "version": "1.4.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "lcid": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/os-name": { "version": "4.0.1", "dev": true, @@ -18648,8 +18282,9 @@ "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" }, "node_modules/picocolors": { - "version": "1.0.0", - "license": "ISC" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -18698,8 +18333,9 @@ "version": "1.0.0" }, "node_modules/pony-cause": { - "version": "2.1.10", - "license": "0BSD", + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", "engines": { "node": ">=12.0.0" } @@ -18726,28 +18362,20 @@ } }, "node_modules/popsicle-cookie-jar": { - "version": "1.0.0", - "license": "MIT", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/popsicle-cookie-jar/-/popsicle-cookie-jar-1.0.1.tgz", + "integrity": "sha512-QVIZhADP8nDbXIQW6wq8GU9IOSE8INUACO/9KD9TFKQ7qq8r/y3qUDz59xIi6p6TH19lCJJyBAPSXP1liIoySw==", "dependencies": { - "@types/tough-cookie": "^2.3.5", - "tough-cookie": "^3.0.1" + "@types/tough-cookie": "^4.0.2", + "tough-cookie": "^4.1.3" + }, + "engines": { + "node": ">=8" }, "peerDependencies": { "servie": "^4.0.0" } }, - "node_modules/popsicle-cookie-jar/node_modules/tough-cookie": { - "version": "3.0.1", - "license": "BSD-3-Clause", - "dependencies": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/popsicle-redirects": { "version": "1.1.0", "license": "MIT", @@ -18780,7 +18408,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "funding": [ { "type": "opencollective", @@ -18789,13 +18419,16 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -19272,6 +18905,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "license": "MIT" @@ -19296,13 +18939,15 @@ } }, "node_modules/prom-client": { - "version": "13.2.0", - "license": "Apache-2.0", + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", "dependencies": { + "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" }, "engines": { - "node": ">=10" + "node": "^16 || ^18 || >=20" } }, "node_modules/promise-breaker": { @@ -19481,71 +19126,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.5.1", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/depd": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "peer": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-is": { "version": "18.2.0", "dev": true, @@ -20648,7 +20228,8 @@ }, "node_modules/saslprep": { "version": "1.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" @@ -20782,20 +20363,6 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/http-errors": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -20810,13 +20377,6 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serialize-javascript": { "version": "6.0.0", "dev": true, @@ -20880,13 +20440,16 @@ "license": "ISC" }, "node_modules/set-function-length": { - "version": "1.1.1", - "license": "MIT", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -20904,12 +20467,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "license": "ISC" @@ -20978,12 +20535,17 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -21021,9 +20583,10 @@ "license": "MIT" }, "node_modules/simple-update-notifier": { - "version": "1.0.7", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", "dev": true, - "license": "MIT", "dependencies": { "semver": "~7.0.0" }, @@ -21160,12 +20723,12 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", - "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", "dependencies": { "debug": "~4.3.4", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "node_modules/socket.io-adapter/node_modules/debug": { @@ -21184,26 +20747,6 @@ } } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "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/socket.io-client": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", @@ -21236,14 +20779,15 @@ "license": "X11" }, "node_modules/socks": { - "version": "2.7.1", - "license": "MIT", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -21255,8 +20799,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "license": "BSD-3-Clause", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -21347,12 +20892,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/splitargs": { - "version": "0.0.7", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/sprintf-js": { "version": "1.0.3", "license": "BSD-3-Clause" @@ -21515,6 +21054,14 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stealthy-require": { "version": "1.1.1", "license": "ISC", @@ -21586,7 +21133,8 @@ }, "node_modules/string-argv": { "version": "0.3.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "engines": { "node": ">=0.6.19" } @@ -21991,23 +21539,6 @@ "node": ">=6" } }, - "node_modules/tar": { - "version": "6.1.11", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -22257,7 +21788,8 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": { "is-number": "^7.0.0" }, @@ -22973,7 +22505,8 @@ }, "node_modules/umzug": { "version": "3.2.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.2.1.tgz", + "integrity": "sha512-XyWQowvP9CKZycKc/Zg9SYWrAWX/gJCE799AUTFqk8yC3tp44K1xWr3LoFF0MNEjClKOo1suCr5ASnoy+KltdA==", "dependencies": { "@rushstack/ts-command-line": "^4.12.2", "emittery": "^0.12.1", @@ -22988,14 +22521,16 @@ }, "node_modules/umzug/node_modules/brace-expansion": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/umzug/node_modules/emittery": { "version": "0.12.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.12.1.tgz", + "integrity": "sha512-pYyW59MIZo0HxPFf+Vb3+gacUu0gxVS3TZwB2ClwkEZywgF9f9OJDoVmNLojTn0vKX3tO9LC+pdQEcLP4Oz/bQ==", "engines": { "node": ">=12" }, @@ -23005,7 +22540,9 @@ }, "node_modules/umzug/node_modules/glob": { "version": "8.1.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -23022,7 +22559,8 @@ }, "node_modules/umzug/node_modules/minimatch": { "version": "5.1.6", - "license": "ISC", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -23032,7 +22570,8 @@ }, "node_modules/umzug/node_modules/type-fest": { "version": "2.19.0", - "license": "(MIT OR CC0-1.0)", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "engines": { "node": ">=12.20" }, @@ -23063,10 +22602,11 @@ "license": "MIT" }, "node_modules/undici": { - "version": "5.25.2", - "license": "MIT", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { "node": ">=14.0" @@ -23121,50 +22661,6 @@ "node": ">=8" } }, - "node_modules/unzipper": { - "version": "0.8.14", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", - "duplexer2": "~0.1.4", - "fstream": "~1.0.10", - "listenercount": "~1.0.1", - "readable-stream": "~2.1.5", - "setimmediate": "~1.0.4" - } - }, - "node_modules/unzipper/node_modules/bluebird": { - "version": "3.4.7", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/unzipper/node_modules/process-nextick-args": { - "version": "1.0.7", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/unzipper/node_modules/readable-stream": { - "version": "2.1.5", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-shims": "^1.0.0", - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "~1.0.0", - "process-nextick-args": "~1.0.6", - "string_decoder": "~0.10.x", - "util-deprecate": "~1.0.1" - } - }, "node_modules/upath": { "version": "2.0.1", "license": "MIT", @@ -23188,12 +22684,6 @@ "querystring": "0.2.0" } }, - "node_modules/url-join": { - "version": "0.0.1", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/url-parse": { "version": "1.5.10", "license": "MIT", @@ -23203,8 +22693,9 @@ } }, "node_modules/url-template": { - "version": "3.1.0", - "license": "BSD-3-Clause", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.1.tgz", + "integrity": "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -23578,18 +23069,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/window-size": { - "version": "0.1.4", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "window-size": "cli.js" - }, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/windows-release": { "version": "4.0.0", "dev": true, @@ -23922,12 +23401,6 @@ "yjs": "^13.0.0" } }, - "node_modules/y18n": { - "version": "3.2.2", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/yaassertion": { "version": "1.0.2", "license": "MIT" @@ -23944,21 +23417,6 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "3.32.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "camelcase": "^2.0.1", - "cliui": "^3.0.3", - "decamelize": "^1.1.1", - "os-locale": "^1.4.0", - "string-width": "^1.0.1", - "window-size": "^0.1.4", - "y18n": "^3.2.0" - } - }, "node_modules/yargs-parser": { "version": "20.2.4", "license": "ISC", @@ -24002,62 +23460,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "2.1.1", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yargs/node_modules/camelcase": { - "version": "2.1.1", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "1.0.2", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "3.0.1", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/yauzl": { "version": "2.10.0", "license": "MIT", diff --git a/package.json b/package.json index f6a49429524..e2756242a9e 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@golevelup/nestjs-rabbitmq": "^4.0.0", "@hendt/xml2json": "^1.0.3", "@hpi-schul-cloud/commons": "^1.3.4", - "@keycloak/keycloak-admin-client": "^23.0.6", + "@keycloak/keycloak-admin-client": "^25.0.1", "@lumieducation/h5p-server": "^9.2.0", "@mikro-orm/cli": "^5.6.16", "@mikro-orm/core": "^5.6.16", @@ -234,7 +234,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfmake": "^0.2.9", - "prom-client": "^13.1.0", + "prom-client": "^15.1.3", "qs": "^6.9.7", "read-chunk": "^3.0.0", "reflect-metadata": "^0.1.13", diff --git a/scripts/startNewSchoolYear.js b/scripts/startNewSchoolYear.js deleted file mode 100644 index 526b55e90f0..00000000000 --- a/scripts/startNewSchoolYear.js +++ /dev/null @@ -1,60 +0,0 @@ -const appPromise = require('../src/app'); - -const { info, error } = require('../src/logger'); - -const { yearModel, schoolModel } = require('../src/services/school/model'); -const federalStateModel = require('../src/services/federalState/model'); - -const exceptFederalStateNames = ['']; - -const CURRENT_SCHOOL_YEAR = '2022/23'; -const NEXT_SCHOOL_YEAR = '2023/24'; -const MAINTENANCE_START_DATE = new Date('2023-08-01'); - -appPromise - .then(async () => { - const currentSchoolYearId = await yearModel.findOne({ name: CURRENT_SCHOOL_YEAR }).select('_id').lean().exec(); - const nextSchoolYearId = await yearModel.findOne({ name: NEXT_SCHOOL_YEAR }).select('_id').lean().exec(); - - const federalStates = await federalStateModel - .find({ name: { $nin: exceptFederalStateNames } }) - .select('_id name') - .lean() - .exec(); - const federalStateIds = federalStates.map((state) => state._id); - const federalStateNames = federalStates.map((state) => state.name); - federalStateIds.push(null); - info(`Migrating schools in ${federalStateIds.length} federalstates (${federalStateNames.toString()})`); - - info('Setting up Maintenance mode for LDAP schools'); - const resultLdapSchools = await schoolModel - .updateMany( - { - federalState: { $in: federalStateIds }, - ldapSchoolIdentifier: { $exists: true }, - inMaintenanceSince: { $exists: false }, - currentYear: currentSchoolYearId._id, - }, - { inMaintenanceSince: MAINTENANCE_START_DATE } - ) - .exec(); - info(`LDAP Schools set in Maintenance mode: ${resultLdapSchools.modifiedCount} schools updated`); - - const resultNonLdapSchools = await schoolModel - .updateMany( - { - federalState: { $in: federalStateIds }, - $or: [{ ldapSchoolIdentifier: { $exists: false } }, { ldapSchoolIdentifier: '' }], - currentYear: { $exists: true }, - }, - { currentYear: nextSchoolYearId._id } - ) - .exec(); - info(`Non-LDAP Schools changed year: ${resultNonLdapSchools.modifiedCount} schools updated`); - - return process.exit(0); - }) - .catch((err) => { - error(err); - return process.exit(1); - }); diff --git a/src/services/authentication/strategies/TSPStrategy.js b/src/services/authentication/strategies/TSPStrategy.js index e615aa56857..d856578df0f 100644 --- a/src/services/authentication/strategies/TSPStrategy.js +++ b/src/services/authentication/strategies/TSPStrategy.js @@ -177,6 +177,10 @@ class TSPStrategy extends AuthenticationBaseStrategy { // find account and generate JWT payload const account = await app.service('nest-account-service').findByUserId(user._id.toString()); account._id = account.id; + + const now = new Date(); + await app.service('nest-account-service').updateLastLogin(account.id, now); + const { entity } = this.configuration; return { authentication: { strategy: this.name }, diff --git a/src/services/user/firstLogin.js b/src/services/user/firstLogin.js index 0d3358c2c6d..b441fc3e1e7 100644 --- a/src/services/user/firstLogin.js +++ b/src/services/user/firstLogin.js @@ -77,14 +77,6 @@ const firstLogin = async (data, params, app) => { userUpdate.birthday = parseDate(data.studentBirthdate); } - // email - if (data['student-email']) { - if (!constants.expressions.email.test(data['student-email'])) { - throw new Error('Bitte eine valide E-Mail-Adresse eingeben.'); - } - userUpdate.email = data['student-email']; - } - // TODO: consent also part of user now, why not patch by this request. Is the third parameter really needed? userPromise = app.service('users').patch(user._id, userUpdate, { account: params.account }); diff --git a/src/services/user/hooks/publicTeachers.js b/src/services/user/hooks/publicTeachers.js index c44e7eba5cf..9a59e3a6fdd 100644 --- a/src/services/user/hooks/publicTeachers.js +++ b/src/services/user/hooks/publicTeachers.js @@ -26,7 +26,7 @@ const mapRoleFilterQuery = (hook) => { const filterForPublicTeacher = (hook) => { // Limit accessible fields - hook.params.query.$select = ['_id', 'firstName', 'lastName']; + hook.params.query.$select = ['_id', 'firstName', 'lastName', 'schoolId']; // Limit accessible user (only teacher which are discoverable) hook.params.query.roles = ['teacher']; diff --git a/src/services/user/hooks/userService.js b/src/services/user/hooks/userService.js index b2b33a60e7f..7a76abc3f57 100644 --- a/src/services/user/hooks/userService.js +++ b/src/services/user/hooks/userService.js @@ -1,632 +1,630 @@ -const { authenticate } = require('@feathersjs/authentication'); -const { keep } = require('feathers-hooks-common'); - -const { Forbidden, NotFound, BadRequest, GeneralError } = require('../../../errors'); -const logger = require('../../../logger'); -const { ObjectId } = require('../../../helper/compare'); -const { hasRoleNoHook, hasPermissionNoHook, hasPermission } = require('../../../hooks'); - -const { getAge } = require('../../../utils'); - -const constants = require('../../../utils/constants'); -const { CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS, SC_DOMAIN } = require('../../../../config/globals'); - -/** - * - * @param {object} hook - The hook of the server-request, containing req.params.query.roles as role-filter - * @returns {Promise } - */ -const mapRoleFilterQuery = (hook) => { - if (hook.params.query.roles) { - const rolesFilter = hook.params.query.roles; - hook.params.query.roles = {}; - hook.params.query.roles.$in = rolesFilter; - } - - return Promise.resolve(hook); -}; -const getProtectedRoles = (hook) => - hook.app.service('/roles').find({ - // load protected roles - query: { - // TODO: cache these - name: ['teacher', 'admin'], - }, - }); - -const checkUnique = (hook) => { - const userService = hook.service; - const { email } = hook.data; - if (email === undefined) { - return Promise.reject(new BadRequest('Fehler beim Auslesen der E-Mail-Adresse bei der Nutzererstellung.')); - } - return userService.find({ query: { email: email.toLowerCase() } }).then((result) => { - const { length } = result.data; - if (length === undefined || length >= 2) { - return Promise.reject(new BadRequest('Fehler beim Prüfen der Datenbankinformationen.')); - } - if (length === 0) { - return Promise.resolve(hook); - } - - const user = typeof result.data[0] === 'object' ? result.data[0] : {}; - const input = typeof hook.data === 'object' ? hook.data : {}; - const isLoggedIn = (hook.params || {}).account && hook.params.account.userId; - // eslint-disable-next-line no-underscore-dangle - const { asTask } = hook.params._additional || {}; - - if (isLoggedIn || asTask === undefined || asTask === 'student') { - return Promise.reject(new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`)); - } - return Promise.resolve(hook); - }); -}; - -const checkUniqueEmail = async (hook) => { - const { email } = hook.data; - if (!email) { - // there is no email address given. Nothing to check... - return Promise.resolve(hook); - } - - // get userId of user entry to edit - const editUserId = hook.id ? hook.id.toString() : undefined; - const unique = await hook.app.service('nest-account-validation-service').isUniqueEmailForUser(email, editUserId); - - if (unique) { - return hook; - } - throw new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`); -}; - -const checkUniqueAccount = (hook) => { - const { email } = hook.data; - return hook.app - .service('nest-account-service') - .searchByUsernameExactMatch(email.toLowerCase()) - .then(([result]) => { - if (result.length > 0) { - throw new BadRequest(`Ein Account mit dieser E-Mail Adresse ${email} existiert bereits!`); - } - return hook; - }); -}; - -const updateAccountUsername = async (context) => { - let { - params: { account }, - } = context; - const { - data: { email }, - app, - } = context; - - if (!email) { - return context; - } - - if (!context.id) { - throw new BadRequest('Id is required for email changes'); - } - - if (!account || !ObjectId.equal(context.id, account.userId)) { - account = await app.service('nest-account-service').findByUserId(context.id); - - if (!account) return context; - } - - if (email && account.systemId) { - delete context.data.email; - return context; - } - - await app - .service('nest-account-service') - .updateUsername(account.id ? account.id : account._id.toString(), email) - .catch((err) => { - throw new BadRequest('Can not update account username.', err); - }); - return context; -}; - -const removeStudentFromClasses = async (hook) => { - // todo: move this functionality into classes, using events. - // todo: what about teachers? - const classesService = hook.app.service('/classes'); - const userIds = hook.id || (hook.result || []).map((u) => u._id); - if (userIds === undefined) { - throw new BadRequest( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' - ); - } - - try { - const usersClasses = await classesService.find({ query: { userIds: { $in: userIds } } }); - await Promise.all( - usersClasses.data.map((klass) => classesService.patch(klass._id, { $pull: { userIds: { $in: userIds } } })) - ); - } catch (err) { - throw new Forbidden( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', - err - ); - } - - return hook; -}; - -const removeStudentFromCourses = async (hook) => { - // todo: move this functionality into courses, using events. - // todo: what about teachers? - const coursesService = hook.app.service('/courses'); - const userIds = hook.id || (hook.result || []).map((u) => u._id); - if (userIds === undefined) { - throw new BadRequest( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' - ); - } - - try { - const usersCourses = await coursesService.find({ query: { userIds: { $in: userIds } } }); - await Promise.all( - usersCourses.data.map((course) => - hook.app.service('courseModel').patch(course._id, { $pull: { userIds: { $in: userIds } } }) - ) - ); - } catch (err) { - throw new Forbidden( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', - err - ); - } -}; - -const sanitizeData = (hook) => { - if ('email' in hook.data) { - if (!constants.expressions.email.test(hook.data.email)) { - return Promise.reject(new BadRequest('Bitte gib eine valide E-Mail Adresse an!')); - } - } - const idRegExp = RegExp('^[0-9a-fA-F]{24}$'); - if ('schoolId' in hook.data) { - if (!idRegExp.test(hook.data.schoolId)) { - return Promise.reject(new BadRequest('invalid Id')); - } - } - if ('classId' in hook.data) { - if (!idRegExp.test(hook.data.classId)) { - return Promise.reject(new BadRequest('invalid Id')); - } - } - return Promise.resolve(hook); -}; - -const checkJwt = () => - function checkJwtfnc(hook) { - if (((hook.params || {}).headers || {}).authorization !== undefined) { - return authenticate('jwt').call(this, hook); - } - return Promise.resolve(hook); - }; - -const pinIsVerified = (hook) => { - if ((hook.params || {}).account && hook.params.account.userId) { - return hasPermission(['STUDENT_CREATE', 'TEACHER_CREATE', 'ADMIN_CREATE']).call(this, hook); - } - // eslint-disable-next-line no-underscore-dangle - const email = (hook.params._additional || {}).parentEmail || hook.data.email; - return hook.app - .service('/registrationPins') - .find({ query: { email, verified: true } }) - .then((pins) => { - if (pins.data.length === 1 && pins.data[0].pin) { - const age = getAge(hook.data.birthday); - - if (!((hook.data.roles || []).includes('student') && age < CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS)) { - hook.app.service('/registrationPins').remove(pins.data[0]._id); - } - - return Promise.resolve(hook); - } - return Promise.reject(new BadRequest('Der Pin wurde noch nicht bei der Registrierung eingetragen.')); - }); -}; - -const protectImmutableAttributes = async (context) => { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) return context; - - delete context.data.roles; - delete (context.data.$push || {}).roles; - delete (context.data.$pull || {}).roles; - delete (context.data.$pop || {}).roles; - delete (context.data.$addToSet || {}).roles; - delete (context.data.$pullAll || {}).roles; - delete (context.data.$set || {}).roles; - - delete context.data.schoolId; - delete (context.data.$set || {}).schoolId; - - return context; -}; - -const securePatching = async (context) => { - const targetUser = await context.app.service('users').get(context.id, { query: { $populate: 'roles' } }); - const actingUser = await context.app - .service('users') - .get(context.params.account.userId, { query: { $populate: 'roles' } }); - const isSuperHero = actingUser.roles.find((r) => r.name === 'superhero'); - const isAdmin = actingUser.roles.find((r) => r.name === 'administrator'); - const isTeacher = actingUser.roles.find((r) => r.name === 'teacher'); - const targetIsStudent = targetUser.roles.find((r) => r.name === 'student'); - - if (isSuperHero) { - return context; - } - - if (!ObjectId.equal(targetUser.schoolId, actingUser.schoolId)) { - return Promise.reject(new NotFound(`no record found for id '${context.id.toString()}'`)); - } - - if (!ObjectId.equal(context.id, context.params.account.userId)) { - if (!(isAdmin || (isTeacher && targetIsStudent))) { - return Promise.reject(new BadRequest('You have not the permissions to change other users')); - } - } - return Promise.resolve(context); -}; - -const formatLastName = (name, isOutdated) => `${name}${isOutdated ? ' ~~' : ''}`; - -/** - * - * @param user {object} - the user the display name has to be generated - * @param app {object} - the global feathers-app - * @returns {string} - a display name of the given user - */ -const getDisplayName = (user, protectedRoles) => { - const protectedRoleIds = (protectedRoles.data || []).map((role) => role._id); - const isProtectedUser = protectedRoleIds.find((role) => (user.roles || []).includes(role)); - - const isOutdated = !!user.outdatedSince; - - user.age = getAge(user.birthday); - - if (isProtectedUser) { - return user.lastName ? formatLastName(user.lastName, isOutdated) : user._id; - } - return user.lastName ? `${user.firstName} ${formatLastName(user.lastName, isOutdated)}` : user._id; -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated user - */ -const decorateUser = async (hook) => { - const protectedRoles = await getProtectedRoles(hook); - const displayName = getDisplayName(hook.result, protectedRoles); - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - hook.result.displayName = displayName; - return hook; -}; - -/** - * - * @param user {object} - a user - * @returns {object} - a user with avatar info - */ -const setAvatarData = (user) => { - if (user.firstName && user.lastName) { - user.avatarInitials = user.firstName.charAt(0) + user.lastName.charAt(0); - } else { - user.avatarInitials = '?'; - } - // css readable value like "#ff0000" needed - const colors = ['#4a4e4d', '#0e9aa7', '#3da4ab', '#f6cd61', '#fe8a71']; - if (user.customAvatarBackgroundColor) { - user.avatarBackgroundColor = user.customAvatarBackgroundColor; - } else { - // choose colors based on initials - const index = (user.avatarInitials.charCodeAt(0) + user.avatarInitials.charCodeAt(1)) % colors.length; - user.avatarBackgroundColor = colors[index]; - } - return user; -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated user avatar - */ -const decorateAvatar = (hook) => { - if (hook.result.total) { - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - (hook.result.data || []).forEach((user) => setAvatarData(user)); - } else { - // run and find with only one user - hook.result = setAvatarData(hook.result); - } - - return Promise.resolve(hook); -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated users - */ -const decorateUsers = async (hook) => { - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - const protectedRoles = await getProtectedRoles(hook); - const users = (hook.result.data || []).map((user) => { - user.displayName = getDisplayName(user, protectedRoles); - return user; - }); - hook.result.data = users; - return hook; -}; - -const handleClassId = (hook) => { - if (!('classId' in hook.data)) { - return Promise.resolve(hook); - } - return hook.app - .service('/classes') - .patch(hook.data.classId, { - $push: { userIds: hook.result._id }, - }) - .then((res) => Promise.resolve(hook)); -}; - -const pushRemoveEvent = (hook) => { - hook.app.emit('users:after:remove', hook); - return hook; -}; - -const enforceRoleHierarchyOnDeleteSingle = async (context) => { - try { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) return context; - - const [targetIsStudent, targetIsTeacher, targetIsAdmin] = await Promise.all([ - hasRoleNoHook(context, context.id, 'student'), - hasRoleNoHook(context, context.id, 'teacher'), - hasRoleNoHook(context, context.id, 'administrator'), - ]); - let permissionChecks = [true]; - if (targetIsStudent) { - permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'STUDENT_DELETE')); - } - if (targetIsTeacher) { - permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'TEACHER_DELETE')); - } - if (targetIsAdmin) { - permissionChecks.push(hasRoleNoHook(context, context.params.account.userId, 'superhero')); - } - permissionChecks = await Promise.all(permissionChecks); - - if (!permissionChecks.reduce((accumulator, val) => accumulator && val)) { - throw new Forbidden('you dont have permission to delete this user!'); - } - - return context; - } catch (error) { - logger.error(error); - throw new Forbidden('you dont have permission to delete this user!'); - } -}; - -const enforceRoleHierarchyOnDeleteBulk = async (context) => { - const user = await context.app.service('users').get(context.params.account.userId); - const canDeleteStudent = user.permissions.includes('STUDENT_DELETE'); - const canDeleteTeacher = user.permissions.includes('TEACHER_DELETE'); - const rolePromises = []; - if (canDeleteStudent) { - rolePromises.push( - context.app - .service('roles') - .find({ query: { name: 'student' } }) - .then((r) => r.data[0]._id) - ); - } - if (canDeleteTeacher) { - rolePromises.push( - context.app - .service('roles') - .find({ query: { name: 'teacher' } }) - .then((r) => r.data[0]._id) - ); - } - const allowedRoles = await Promise.all(rolePromises); - - // there may not be any role in user.roles that is not in rolesToDelete - const roleQuery = { $nor: [{ roles: { $elemMatch: { $nin: allowedRoles } } }] }; - context.params.query = { $and: [context.params.query, roleQuery] }; - return context; -}; - -const enforceRoleHierarchyOnDelete = async (context) => { - if (context.id) return enforceRoleHierarchyOnDeleteSingle(context); - return enforceRoleHierarchyOnDeleteBulk(context); -}; - -/** - * Check that the authenticated user posseses the rights to create a user with the given roles. - * This is only checked for external requests. - * @param {*} context - */ -const enforceRoleHierarchyOnCreate = async (context) => { - const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); - - // superhero may create users with every role - if (user.roles.filter((u) => u.name === 'superhero').length > 0) { - return Promise.resolve(context); - } - - // created user has no role - if (!context.data || !context.data.roles) { - return Promise.resolve(context); - } - await Promise.all( - context.data.roles.map(async (roleId) => { - // Roles are given by ID or by name. - // For IDs we load the name from the DB. - // If it is not an ID we assume, it is a name. Invalid names are rejected in the switch anyways. - let roleName = ''; - if (!ObjectId.isValid(roleId)) { - roleName = roleId; - } else { - try { - const role = await context.app.service('roles').get(roleId); - roleName = role.name; - } catch (exception) { - return Promise.reject(new BadRequest('No such role exists')); - } - } - switch (roleName) { - case 'teacher': - if (!user.permissions.find((permission) => permission === 'TEACHER_CREATE')) { - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - break; - case 'student': - if (!user.permissions.find((permission) => permission === 'STUDENT_CREATE')) { - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - break; - case 'parent': - break; - default: - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - return Promise.resolve(context); - }) - ); - - return Promise.resolve(context); -}; - -const generateRegistrationLink = async (context) => { - const { data, app } = context; - if (data.generateRegistrationLink === true) { - delete data.generateRegistrationLink; - if (!data.roles || data.roles.length > 1) { - throw new BadRequest('Roles must be exactly of length one if generateRegistrationLink=true is set.'); - } - const { hash } = await app - .service('/registrationlink') - // set account in params to context.parmas.account to reference the current user - .create({ - role: data.roles[0], - save: true, - patchUser: true, - host: SC_DOMAIN, - schoolId: data.schoolId, - toHash: data.email, - }) - .catch((err) => { - throw new GeneralError(`Can not create registrationlink. ${err}`); - }); - context.data.importHash = hash; - } -}; - -const sendRegistrationLink = async (context) => { - const { result, data, app } = context; - if (data.sendRegistration === true) { - delete data.sendRegistration; - await app.service('/users/mail/registrationLink').create({ - users: [result], - }); - } - return context; -}; - -const filterResult = async (context) => { - const userCallingHimself = context.id && ObjectId.equal(context.id, context.params.account.userId); - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userCallingHimself || userIsSuperhero) { - return context; - } - - const allowedAttributes = [ - '_id', - 'roles', - 'schoolId', - 'firstName', - 'middleName', - 'lastName', - 'namePrefix', - 'nameSuffix', - 'discoverable', - 'fullName', - 'displayName', - 'avatarInitials', - 'avatarBackgroundColor', - 'outdatedSince', - ]; - return keep(...allowedAttributes)(context); -}; - -let roleCache = null; -const includeOnlySchoolRoles = async (context) => { - if (context.params && context.params.query) { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) { - return context; - } - - // todo: remove with static role service (SC-3731) - if (!Array.isArray(roleCache)) { - roleCache = ( - await context.app.service('roles').find({ - query: { - name: { $in: ['administrator', 'teacher', 'student'] }, - }, - paginate: false, - }) - ).map((r) => r._id); - } - const allowedRoles = roleCache; - - if (context.params.query.roles && context.params.query.roles.$in) { - // when querying for specific roles, filter them - context.params.query.roles.$in = context.params.query.roles.$in.filter((r) => - allowedRoles.some((a) => ObjectId.equal(r, a)) - ); - } else { - // otherwise, overwrite them with whitelist - context.params.query.roles = { - $in: allowedRoles, - }; - } - } - return context; -}; - -module.exports = { - mapRoleFilterQuery, - checkUnique, - checkUniqueEmail, - checkJwt, - checkUniqueAccount, - updateAccountUsername, - removeStudentFromClasses, - removeStudentFromCourses, - sanitizeData, - pinIsVerified, - protectImmutableAttributes, - securePatching, - decorateUser, - decorateAvatar, - decorateUsers, - handleClassId, - pushRemoveEvent, - enforceRoleHierarchyOnDelete, - enforceRoleHierarchyOnCreate, - filterResult, - generateRegistrationLink, - sendRegistrationLink, - includeOnlySchoolRoles, -}; +const { authenticate } = require('@feathersjs/authentication'); +const { keep } = require('feathers-hooks-common'); + +const { Forbidden, NotFound, BadRequest, GeneralError } = require('../../../errors'); +const logger = require('../../../logger'); +const { ObjectId } = require('../../../helper/compare'); +const { hasRoleNoHook, hasPermissionNoHook, hasPermission } = require('../../../hooks'); + +const { getAge } = require('../../../utils'); + +const constants = require('../../../utils/constants'); +const { CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS, SC_DOMAIN } = require('../../../../config/globals'); + +/** + * + * @param {object} hook - The hook of the server-request, containing req.params.query.roles as role-filter + * @returns {Promise } + */ +const mapRoleFilterQuery = (hook) => { + if (hook.params.query.roles) { + const rolesFilter = hook.params.query.roles; + hook.params.query.roles = {}; + hook.params.query.roles.$in = rolesFilter; + } + + return Promise.resolve(hook); +}; +const getProtectedRoles = (hook) => + hook.app.service('/roles').find({ + // load protected roles + query: { + // TODO: cache these + name: ['teacher', 'admin'], + }, + }); + +const checkUnique = (hook) => { + const userService = hook.service; + const { email } = hook.data; + if (email === undefined) { + return Promise.reject(new BadRequest('Fehler beim Auslesen der E-Mail-Adresse bei der Nutzererstellung.')); + } + return userService.find({ query: { email: email.toLowerCase() } }).then((result) => { + const { length } = result.data; + if (length === undefined || length >= 2) { + return Promise.reject(new BadRequest('Fehler beim Prüfen der Datenbankinformationen.')); + } + if (length === 0) { + return Promise.resolve(hook); + } + + const user = typeof result.data[0] === 'object' ? result.data[0] : {}; + const input = typeof hook.data === 'object' ? hook.data : {}; + const isLoggedIn = (hook.params || {}).account && hook.params.account.userId; + // eslint-disable-next-line no-underscore-dangle + const { asTask } = hook.params._additional || {}; + + if (isLoggedIn || asTask === undefined || asTask === 'student') { + return Promise.reject(new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`)); + } + return Promise.resolve(hook); + }); +}; + +const checkUniqueEmail = async (hook) => { + const { email } = hook.data; + if (!email) { + // there is no email address given. Nothing to check... + return Promise.resolve(hook); + } + + const isUnique = await hook.app.service('nest-account-service').isUniqueEmail(email); + + if (isUnique) { + return hook; + } + throw new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`); +}; + +const checkUniqueAccount = (hook) => { + const { email } = hook.data; + return hook.app + .service('nest-account-service') + .searchByUsernameExactMatch(email.toLowerCase()) + .then(([result]) => { + if (result.length > 0) { + throw new BadRequest(`Ein Account mit dieser E-Mail Adresse ${email} existiert bereits!`); + } + return hook; + }); +}; + +const updateAccountUsername = async (context) => { + let { + params: { account }, + } = context; + const { + data: { email }, + app, + } = context; + + if (!email) { + return context; + } + + if (!context.id) { + throw new BadRequest('Id is required for email changes'); + } + + if (!account || !ObjectId.equal(context.id, account.userId)) { + account = await app.service('nest-account-service').findByUserId(context.id); + + if (!account) return context; + } + + if (email && account.systemId) { + delete context.data.email; + return context; + } + + await app + .service('nest-account-service') + .updateUsername(account.id ? account.id : account._id.toString(), email) + .catch((err) => { + throw new BadRequest('Can not update account username.', err); + }); + return context; +}; + +const removeStudentFromClasses = async (hook) => { + // todo: move this functionality into classes, using events. + // todo: what about teachers? + const classesService = hook.app.service('/classes'); + const userIds = hook.id || (hook.result || []).map((u) => u._id); + if (userIds === undefined) { + throw new BadRequest( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' + ); + } + + try { + const usersClasses = await classesService.find({ query: { userIds: { $in: userIds } } }); + await Promise.all( + usersClasses.data.map((klass) => classesService.patch(klass._id, { $pull: { userIds: { $in: userIds } } })) + ); + } catch (err) { + throw new Forbidden( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', + err + ); + } + + return hook; +}; + +const removeStudentFromCourses = async (hook) => { + // todo: move this functionality into courses, using events. + // todo: what about teachers? + const coursesService = hook.app.service('/courses'); + const userIds = hook.id || (hook.result || []).map((u) => u._id); + if (userIds === undefined) { + throw new BadRequest( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' + ); + } + + try { + const usersCourses = await coursesService.find({ query: { userIds: { $in: userIds } } }); + await Promise.all( + usersCourses.data.map((course) => + hook.app.service('courseModel').patch(course._id, { $pull: { userIds: { $in: userIds } } }) + ) + ); + } catch (err) { + throw new Forbidden( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', + err + ); + } +}; + +const sanitizeData = (hook) => { + if ('email' in hook.data) { + if (!constants.expressions.email.test(hook.data.email)) { + return Promise.reject(new BadRequest('Bitte gib eine valide E-Mail Adresse an!')); + } + } + const idRegExp = RegExp('^[0-9a-fA-F]{24}$'); + if ('schoolId' in hook.data) { + if (!idRegExp.test(hook.data.schoolId)) { + return Promise.reject(new BadRequest('invalid Id')); + } + } + if ('classId' in hook.data) { + if (!idRegExp.test(hook.data.classId)) { + return Promise.reject(new BadRequest('invalid Id')); + } + } + return Promise.resolve(hook); +}; + +const checkJwt = () => + function checkJwtfnc(hook) { + if (((hook.params || {}).headers || {}).authorization !== undefined) { + return authenticate('jwt').call(this, hook); + } + return Promise.resolve(hook); + }; + +const pinIsVerified = (hook) => { + if ((hook.params || {}).account && hook.params.account.userId) { + return hasPermission(['STUDENT_CREATE', 'TEACHER_CREATE', 'ADMIN_CREATE']).call(this, hook); + } + // eslint-disable-next-line no-underscore-dangle + const email = (hook.params._additional || {}).parentEmail || hook.data.email; + return hook.app + .service('/registrationPins') + .find({ query: { email, verified: true } }) + .then((pins) => { + if (pins.data.length === 1 && pins.data[0].pin) { + const age = getAge(hook.data.birthday); + + if (!((hook.data.roles || []).includes('student') && age < CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS)) { + hook.app.service('/registrationPins').remove(pins.data[0]._id); + } + + return Promise.resolve(hook); + } + return Promise.reject(new BadRequest('Der Pin wurde noch nicht bei der Registrierung eingetragen.')); + }); +}; + +const protectImmutableAttributes = async (context) => { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) return context; + + delete context.data.roles; + delete (context.data.$push || {}).roles; + delete (context.data.$pull || {}).roles; + delete (context.data.$pop || {}).roles; + delete (context.data.$addToSet || {}).roles; + delete (context.data.$pullAll || {}).roles; + delete (context.data.$set || {}).roles; + + delete context.data.schoolId; + delete (context.data.$set || {}).schoolId; + + return context; +}; + +const securePatching = async (context) => { + const targetUser = await context.app.service('users').get(context.id, { query: { $populate: 'roles' } }); + const actingUser = await context.app + .service('users') + .get(context.params.account.userId, { query: { $populate: 'roles' } }); + const isSuperHero = actingUser.roles.find((r) => r.name === 'superhero'); + const isAdmin = actingUser.roles.find((r) => r.name === 'administrator'); + const isTeacher = actingUser.roles.find((r) => r.name === 'teacher'); + const targetIsStudent = targetUser.roles.find((r) => r.name === 'student'); + + if (isSuperHero) { + return context; + } + + if (!ObjectId.equal(targetUser.schoolId, actingUser.schoolId)) { + return Promise.reject(new NotFound(`no record found for id '${context.id.toString()}'`)); + } + + if (!ObjectId.equal(context.id, context.params.account.userId)) { + if (!(isAdmin || (isTeacher && targetIsStudent))) { + return Promise.reject(new BadRequest('You have not the permissions to change other users')); + } + } + return Promise.resolve(context); +}; + +const formatLastName = (name, isOutdated) => `${name}${isOutdated ? ' ~~' : ''}`; + +/** + * + * @param user {object} - the user the display name has to be generated + * @param app {object} - the global feathers-app + * @returns {string} - a display name of the given user + */ +const getDisplayName = (user, protectedRoles) => { + const protectedRoleIds = (protectedRoles.data || []).map((role) => role._id); + const isProtectedUser = protectedRoleIds.find((role) => (user.roles || []).includes(role)); + + const isOutdated = !!user.outdatedSince; + + user.age = getAge(user.birthday); + + if (isProtectedUser) { + return user.lastName ? formatLastName(user.lastName, isOutdated) : user._id; + } + return user.lastName ? `${user.firstName} ${formatLastName(user.lastName, isOutdated)}` : user._id; +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated user + */ +const decorateUser = async (hook) => { + const protectedRoles = await getProtectedRoles(hook); + const displayName = getDisplayName(hook.result, protectedRoles); + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + hook.result.displayName = displayName; + return hook; +}; + +/** + * + * @param user {object} - a user + * @returns {object} - a user with avatar info + */ +const setAvatarData = (user) => { + if (user.firstName && user.lastName) { + user.avatarInitials = user.firstName.charAt(0) + user.lastName.charAt(0); + } else { + user.avatarInitials = '?'; + } + // css readable value like "#ff0000" needed + const colors = ['#4a4e4d', '#0e9aa7', '#3da4ab', '#f6cd61', '#fe8a71']; + if (user.customAvatarBackgroundColor) { + user.avatarBackgroundColor = user.customAvatarBackgroundColor; + } else { + // choose colors based on initials + const index = (user.avatarInitials.charCodeAt(0) + user.avatarInitials.charCodeAt(1)) % colors.length; + user.avatarBackgroundColor = colors[index]; + } + return user; +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated user avatar + */ +const decorateAvatar = (hook) => { + if (hook.result.total) { + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + (hook.result.data || []).forEach((user) => setAvatarData(user)); + } else { + // run and find with only one user + hook.result = setAvatarData(hook.result); + } + + return Promise.resolve(hook); +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated users + */ +const decorateUsers = async (hook) => { + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + const protectedRoles = await getProtectedRoles(hook); + const users = (hook.result.data || []).map((user) => { + user.displayName = getDisplayName(user, protectedRoles); + return user; + }); + hook.result.data = users; + return hook; +}; + +const handleClassId = (hook) => { + if (!('classId' in hook.data)) { + return Promise.resolve(hook); + } + return hook.app + .service('/classes') + .patch(hook.data.classId, { + $push: { userIds: hook.result._id }, + }) + .then((res) => Promise.resolve(hook)); +}; + +const pushRemoveEvent = (hook) => { + hook.app.emit('users:after:remove', hook); + return hook; +}; + +const enforceRoleHierarchyOnDeleteSingle = async (context) => { + try { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) return context; + + const [targetIsStudent, targetIsTeacher, targetIsAdmin] = await Promise.all([ + hasRoleNoHook(context, context.id, 'student'), + hasRoleNoHook(context, context.id, 'teacher'), + hasRoleNoHook(context, context.id, 'administrator'), + ]); + let permissionChecks = [true]; + if (targetIsStudent) { + permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'STUDENT_DELETE')); + } + if (targetIsTeacher) { + permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'TEACHER_DELETE')); + } + if (targetIsAdmin) { + permissionChecks.push(hasRoleNoHook(context, context.params.account.userId, 'superhero')); + } + permissionChecks = await Promise.all(permissionChecks); + + if (!permissionChecks.reduce((accumulator, val) => accumulator && val)) { + throw new Forbidden('you dont have permission to delete this user!'); + } + + return context; + } catch (error) { + logger.error(error); + throw new Forbidden('you dont have permission to delete this user!'); + } +}; + +const enforceRoleHierarchyOnDeleteBulk = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId); + const canDeleteStudent = user.permissions.includes('STUDENT_DELETE'); + const canDeleteTeacher = user.permissions.includes('TEACHER_DELETE'); + const rolePromises = []; + if (canDeleteStudent) { + rolePromises.push( + context.app + .service('roles') + .find({ query: { name: 'student' } }) + .then((r) => r.data[0]._id) + ); + } + if (canDeleteTeacher) { + rolePromises.push( + context.app + .service('roles') + .find({ query: { name: 'teacher' } }) + .then((r) => r.data[0]._id) + ); + } + const allowedRoles = await Promise.all(rolePromises); + + // there may not be any role in user.roles that is not in rolesToDelete + const roleQuery = { $nor: [{ roles: { $elemMatch: { $nin: allowedRoles } } }] }; + context.params.query = { $and: [context.params.query, roleQuery] }; + return context; +}; + +const enforceRoleHierarchyOnDelete = async (context) => { + if (context.id) return enforceRoleHierarchyOnDeleteSingle(context); + return enforceRoleHierarchyOnDeleteBulk(context); +}; + +/** + * Check that the authenticated user posseses the rights to create a user with the given roles. + * This is only checked for external requests. + * @param {*} context + */ +const enforceRoleHierarchyOnCreate = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); + + // superhero may create users with every role + if (user.roles.filter((u) => u.name === 'superhero').length > 0) { + return Promise.resolve(context); + } + + // created user has no role + if (!context.data || !context.data.roles) { + return Promise.resolve(context); + } + await Promise.all( + context.data.roles.map(async (roleId) => { + // Roles are given by ID or by name. + // For IDs we load the name from the DB. + // If it is not an ID we assume, it is a name. Invalid names are rejected in the switch anyways. + let roleName = ''; + if (!ObjectId.isValid(roleId)) { + roleName = roleId; + } else { + try { + const role = await context.app.service('roles').get(roleId); + roleName = role.name; + } catch (exception) { + return Promise.reject(new BadRequest('No such role exists')); + } + } + switch (roleName) { + case 'teacher': + if (!user.permissions.find((permission) => permission === 'TEACHER_CREATE')) { + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + break; + case 'student': + if (!user.permissions.find((permission) => permission === 'STUDENT_CREATE')) { + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + break; + case 'parent': + break; + default: + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + return Promise.resolve(context); + }) + ); + + return Promise.resolve(context); +}; + +const generateRegistrationLink = async (context) => { + const { data, app } = context; + if (data.generateRegistrationLink === true) { + delete data.generateRegistrationLink; + if (!data.roles || data.roles.length > 1) { + throw new BadRequest('Roles must be exactly of length one if generateRegistrationLink=true is set.'); + } + const { hash } = await app + .service('/registrationlink') + // set account in params to context.parmas.account to reference the current user + .create({ + role: data.roles[0], + save: true, + patchUser: true, + host: SC_DOMAIN, + schoolId: data.schoolId, + toHash: data.email, + }) + .catch((err) => { + throw new GeneralError(`Can not create registrationlink. ${err}`); + }); + context.data.importHash = hash; + } +}; + +const sendRegistrationLink = async (context) => { + const { result, data, app } = context; + if (data.sendRegistration === true) { + delete data.sendRegistration; + await app.service('/users/mail/registrationLink').create({ + users: [result], + }); + } + return context; +}; + +const filterResult = async (context) => { + const userCallingHimself = context.id && ObjectId.equal(context.id, context.params.account.userId); + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userCallingHimself || userIsSuperhero) { + return context; + } + + const allowedAttributes = [ + '_id', + 'roles', + 'schoolId', + 'firstName', + 'middleName', + 'lastName', + 'namePrefix', + 'nameSuffix', + 'discoverable', + 'fullName', + 'displayName', + 'avatarInitials', + 'avatarBackgroundColor', + 'outdatedSince', + ]; + return keep(...allowedAttributes)(context); +}; + +let roleCache = null; +const includeOnlySchoolRoles = async (context) => { + if (context.params && context.params.query) { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) { + return context; + } + + // todo: remove with static role service (SC-3731) + if (!Array.isArray(roleCache)) { + roleCache = ( + await context.app.service('roles').find({ + query: { + name: { $in: ['administrator', 'teacher', 'student'] }, + }, + paginate: false, + }) + ).map((r) => r._id); + } + const allowedRoles = roleCache; + + if (context.params.query.roles && context.params.query.roles.$in) { + // when querying for specific roles, filter them + context.params.query.roles.$in = context.params.query.roles.$in.filter((r) => + allowedRoles.some((a) => ObjectId.equal(r, a)) + ); + } else { + // otherwise, overwrite them with whitelist + context.params.query.roles = { + $in: allowedRoles, + }; + } + } + return context; +}; + +module.exports = { + mapRoleFilterQuery, + checkUnique, + checkUniqueEmail, + checkJwt, + checkUniqueAccount, + updateAccountUsername, + removeStudentFromClasses, + removeStudentFromCourses, + sanitizeData, + pinIsVerified, + protectImmutableAttributes, + securePatching, + decorateUser, + decorateAvatar, + decorateUsers, + handleClassId, + pushRemoveEvent, + enforceRoleHierarchyOnDelete, + enforceRoleHierarchyOnCreate, + filterResult, + generateRegistrationLink, + sendRegistrationLink, + includeOnlySchoolRoles, +}; diff --git a/test/services/sync/strategies/LDAPSyncerConsumer.integration.test.js b/test/services/sync/strategies/LDAPSyncerConsumer.integration.test.js index bcfca245844..8a2ab5cda53 100644 --- a/test/services/sync/strategies/LDAPSyncerConsumer.integration.test.js +++ b/test/services/sync/strategies/LDAPSyncerConsumer.integration.test.js @@ -69,10 +69,10 @@ describe('Ldap Syncer Consumer Integration', () => { it('should create school by the data', async () => { const schoolName = 'test school'; const currentYear = { - _id: '5ebd6dc14a431f75ec9a3e77', - name: '2023/24', - startDate: '2023-08-01T00:00:00.000Z', - endDate: '2024-07-31T00:00:00.000Z', + _id: '5ebd6dc14a431f75ec9a3e7a', + name: '2024/25', + startDate: '2024-08-01T00:00:00.000Z', + endDate: '2025-07-31T00:00:00.000Z', }; const states = await app.service('federalStates').find({ query: { abbreviation: 'NI' } }); const federalStateId = states.data[0]._id; diff --git a/test/services/user/hooks/userService.hooks.test.js b/test/services/user/hooks/userService.hooks.test.js index f779b6a4291..98a49c0594a 100644 --- a/test/services/user/hooks/userService.hooks.test.js +++ b/test/services/user/hooks/userService.hooks.test.js @@ -153,16 +153,18 @@ describe('generateRegistrationLink', () => { const expectedErrorMessage = 'Roles must be exactly of length one if generateRegistrationLink=true is set.'; - const getAppMock = (registrationlinkMock) => ({ - service: (service) => { - if (service === '/registrationlink') { - return { - create: async (data) => registrationlinkMock(data), - }; - } - throw new Error('unknown service'); - }, - }); + const getAppMock = (registrationlinkMock) => { + return { + service: (service) => { + if (service === '/registrationlink') { + return { + create: async (data) => registrationlinkMock(data), + }; + } + throw new Error('unknown service'); + }, + }; + }; it('throws an error if roles is not defined', async () => { const context = { @@ -439,7 +441,6 @@ describe('checkUniqueEmail', () => { const currentTs = Date.now(); const currentEmail = `current.${currentTs}@account.de`; - const updatedEmail = `Current.${currentTs}@Account.DE`; const changedEmail = `Changed.${currentTs}@Account.DE`; const mockUser = { firstName: 'Test', @@ -450,13 +451,14 @@ describe('checkUniqueEmail', () => { it('fails because of duplicate email', async () => { const expectedErrorMessage = `Die E-Mail Adresse ist bereits in Verwendung!`; - await testObjects.createTestUser({ email: currentEmail }); + const user = await testObjects.createTestUser(); + await app.service('nest-account-service').save({ username: user.email, password: 'password', userId: user._id }); const context = { app, data: { ...mockUser, - email: updatedEmail, + email: user.email, }, }; diff --git a/test/utils/setup.nest.services.js b/test/utils/setup.nest.services.js index 3b1fedd050b..377a700b20b 100644 --- a/test/utils/setup.nest.services.js +++ b/test/utils/setup.nest.services.js @@ -8,9 +8,6 @@ const { ConfigModule } = require('@nestjs/config'); const { AccountApiModule } = require('../../dist/apps/server/modules/account/account-api.module'); const { AccountUc } = require('../../dist/apps/server/modules/account/api/account.uc'); const { AccountService } = require('../../dist/apps/server/modules/account/domain/services/account.service'); -const { - AccountValidationService, -} = require('../../dist/apps/server/modules/account/domain/services/account.validation.service'); const { DB_PASSWORD, DB_URL, DB_USERNAME } = require('../../dist/apps/server/config/database.config'); const { ALL_ENTITIES } = require('../../dist/apps/server/shared/domain/entity/all-entities'); const { TeamService } = require('../../dist/apps/server/modules/teams/service/team.service'); @@ -42,13 +39,11 @@ const setupNestServices = async (app) => { const orm = nestApp.get(MikroORM); const accountUc = nestApp.get(AccountUc); 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;