diff --git a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 index 17384845b71..e3843effc48 100644 --- a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: amqp-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: amqp-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ AMQP_FILE_STORAGE_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: amqp-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: amqp-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 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 317d37dcda9..2eb0fdd4094 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 @@ -5,14 +5,18 @@ metadata: labels: app: api cronjob: delete-s3-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: delete-s3-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-delete-s3-files-cronjob spec: concurrencyPolicy: Forbid schedule: "{{ SERVER_FILE_DELETION_CRONJOB_SCHEDULE|default("@hourly", true) }}" jobTemplate: - labels: - app: api - cronjob: delete-s3-files spec: template: spec: @@ -34,3 +38,14 @@ spec: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} restartPolicy: OnFailure + metadata: + labels: + app: api + cronjob: delete-s3-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: delete-s3-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 index 727b5a9f4f0..7866e23dcc3 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_FILE_STORAGE_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 index c8d7e6f894c..5fe3f528bde 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-fwu + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-fwu + app.kubernetes.io/component: fwu + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_FWU_LEARNING_CONTENTS_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-fwu + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-fwu + app.kubernetes.io/component: fwu + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 index 0750bf91495..a18102bb55d 100644 --- a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api + app.kubernetes.io/component: server + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api + app.kubernetes.io/component: server + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 index 51d87b88755..cffa1dc02b8 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ AMQP_FILE_PREVIEW_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 index 2f8f9091b8e..6058b1b0ccb 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 @@ -6,6 +6,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: scaleTargetRef: name: preview-generator-deployment diff --git a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 index 4e41f454ed9..e5923a2e1b9 100644 --- a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-h5p + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-h5p + app.kubernetes.io/component: h5p + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_H5P_EDITOR_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-h5p + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-h5p + app.kubernetes.io/component: h5p + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 b/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 index 9d99c672be8..93378069cc9 100644 --- a/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: management-deployment + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: management-deployment + app.kubernetes.io/component: management + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_MANAGEMENT_REPLICAS|default("1", true) }} strategy: @@ -20,6 +27,13 @@ spec: metadata: labels: app: management-deployment + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: management-deployment + app.kubernetes.io/component: management + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 index 74cc37d75b6..db852a7081f 100644 --- a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 @@ -4,6 +4,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-ldapsync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-ldapsync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-ldapsync-full-cronjob spec: schedule: "{{ SERVER_LDAP_SYNC_FULL_CRONJOB|default("0 3 * * 3,6", true) }}" @@ -30,3 +37,13 @@ spec: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} restartPolicy: OnFailure + metadata: + labels: + app: api-ldapsync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-ldapsync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 index 838009883d4..9f03dbee883 100644 --- a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-worker + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-worker + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_WORKER_REPLICAS|default("2", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-worker + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-worker + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 index f5a3d0751f4..e46dac14330 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 @@ -4,15 +4,19 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-tsp-sync-base-cronjob spec: schedule: "{{ SERVER_TSP_SYNC_BASE_CRONJOB|default("9 3 * * *", true) }}" jobTemplate: spec: template: - metadata: - labels: - app: api-tsp-sync-cronjob spec: containers: - name: api-tsp-sync-base-cronjob @@ -23,3 +27,13 @@ spec: command: ['/bin/sh','-c'] args: ['curl -H "X-API-Key: $SYNC_API_KEY" "http://{{ API_TSP_SYNC_SVC|default("api-tsp-sync-svc", true) }}:3030/api/v1/sync?target=tsp-base" | python3 -m json.tool'] restartPolicy: OnFailure + metadata: + labels: + app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 index 54595985dd4..614db8cf1f9 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_TSP_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-tsp-sync + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 index a92bd92560e..e47ae85fab1 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 @@ -4,15 +4,19 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-tsp-sync-school-cronjob spec: schedule: "{{ SERVER_TSP_SYNC_SCHOOL_CRONJOB|default("39 3 * * *", true) }}" jobTemplate: spec: template: - metadata: - labels: - app: api-tsp-sync-cronjob spec: containers: - name: api-tsp-sync-school-cronjob @@ -23,3 +27,13 @@ spec: command: ['/bin/sh','-c'] args: ['curl -H "X-API-Key: $SYNC_API_KEY" "http://{{ API_TSP_SYNC_SVC|default("api-tsp-sync-svc", true) }}:3030/api/v1/sync?target=tsp-school" | python3 -m json.tool'] restartPolicy: OnFailure + metadata: + labels: + app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/apps/server/src/apps/deletion-console.app.ts b/apps/server/src/apps/deletion-console.app.ts new file mode 100644 index 00000000000..cafb137e160 --- /dev/null +++ b/apps/server/src/apps/deletion-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { BootstrapConsole } from 'nestjs-console'; +import { DeletionConsoleModule } from '@modules/deletion'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: DeletionConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exitCode = 1; + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/core/logger/types/logging.types.ts b/apps/server/src/core/logger/types/logging.types.ts index e8c27380b6f..5271ba85338 100644 --- a/apps/server/src/core/logger/types/logging.types.ts +++ b/apps/server/src/core/logger/types/logging.types.ts @@ -7,7 +7,7 @@ export type ErrorLogMessage = { error?: Error; type: string; // TODO: use enum stack?: string; - data?: { [key: string]: string | number | undefined }; + data?: { [key: string]: string | number | boolean | undefined }; }; export type ValidationErrorLogMessage = { diff --git a/apps/server/src/modules/authentication/errors/index.ts b/apps/server/src/modules/authentication/errors/index.ts index d87d53df8bf..d345cf9b0ce 100644 --- a/apps/server/src/modules/authentication/errors/index.ts +++ b/apps/server/src/modules/authentication/errors/index.ts @@ -1,4 +1,3 @@ export * from './brute-force.error'; export * from './ldap-connection.error'; -export * from './school-in-migration.error'; export * from './unauthorized.loggable-exception'; diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index 59e749c7abc..3a3ea0c2755 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -1,3 +1,4 @@ export { ICurrentUser } from './interface'; export { JWT, CurrentUser, Authenticate } from './decorator'; export { AuthenticationModule } from './authentication.module'; +export { AuthenticationService } from './services'; diff --git a/apps/server/src/modules/authentication/loggable/index.ts b/apps/server/src/modules/authentication/loggable/index.ts new file mode 100644 index 00000000000..7e6fcda9db1 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/index.ts @@ -0,0 +1 @@ +export * from './school-in-migration.loggable-exception'; diff --git a/apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.spec.ts b/apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.spec.ts new file mode 100644 index 00000000000..49f330efa17 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.spec.ts @@ -0,0 +1,24 @@ +import { SchoolInMigrationLoggableException } from './school-in-migration.loggable-exception'; + +describe(SchoolInMigrationLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const exception = new SchoolInMigrationLoggableException(); + + return { + exception, + }; + }; + + it('should return the correct log message', () => { + const { exception } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'SCHOOL_IN_MIGRATION', + stack: expect.any(String), + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/errors/school-in-migration.error.ts b/apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.ts similarity index 51% rename from apps/server/src/modules/authentication/errors/school-in-migration.error.ts rename to apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.ts index ed507b07656..76f5baecb61 100644 --- a/apps/server/src/modules/authentication/errors/school-in-migration.error.ts +++ b/apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.ts @@ -1,16 +1,23 @@ import { HttpStatus } from '@nestjs/common'; import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; -export class SchoolInMigrationError extends BusinessError { - constructor(details?: Record) { +export class SchoolInMigrationLoggableException extends BusinessError implements Loggable { + constructor() { super( { type: 'SCHOOL_IN_MIGRATION', title: 'Login failed because school is in migration', defaultMessage: 'Login failed because creation of user is not possible during migration', }, - HttpStatus.UNAUTHORIZED, - details + HttpStatus.UNAUTHORIZED ); } + + getLogMessage(): ErrorLogMessage { + return { + type: this.type, + stack: this.stack, + }; + } } diff --git a/apps/server/src/modules/authentication/services/index.ts b/apps/server/src/modules/authentication/services/index.ts new file mode 100644 index 00000000000..45277bbf1c5 --- /dev/null +++ b/apps/server/src/modules/authentication/services/index.ts @@ -0,0 +1,2 @@ +export * from './ldap.service'; +export * from './authentication.service'; diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index f67f620175d..428f3c5e048 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -1,15 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AccountService } from '@modules/account/services/account.service'; +import { AccountDto } from '@modules/account/services/dto'; +import { OAuthTokenDto, OAuthService } from '@modules/oauth'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, RoleName } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { userDoFactory } from '@shared/testing'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; -import { OAuthTokenDto } from '@modules/oauth'; -import { OAuthService } from '@modules/oauth/service/oauth.service'; -import { SchoolInMigrationError } from '../errors/school-in-migration.error'; import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { SchoolInMigrationLoggableException } from '../loggable'; import { Oauth2Strategy } from './oauth2.strategy'; describe('Oauth2Strategy', () => { @@ -68,7 +67,7 @@ describe('Oauth2Strategy', () => { refreshToken: 'refreshToken', }) ); - oauthService.provisionUser.mockResolvedValue({ user, redirect: '' }); + oauthService.provisionUser.mockResolvedValue(user); accountService.findByUserId.mockResolvedValue(account); return { systemId, user, account, idToken }; @@ -102,7 +101,7 @@ describe('Oauth2Strategy', () => { refreshToken: 'refreshToken', }) ); - oauthService.provisionUser.mockResolvedValue({ user: undefined, redirect: '' }); + oauthService.provisionUser.mockResolvedValue(null); }; it('should throw a SchoolInMigrationError', async () => { @@ -111,7 +110,7 @@ describe('Oauth2Strategy', () => { const func = async () => strategy.validate({ body: { code: 'code', redirectUri: 'redirectUri', systemId: 'systemId' } }); - await expect(func).rejects.toThrow(new SchoolInMigrationError()); + await expect(func).rejects.toThrow(new SchoolInMigrationLoggableException()); }); }); @@ -126,7 +125,7 @@ describe('Oauth2Strategy', () => { refreshToken: 'refreshToken', }) ); - oauthService.provisionUser.mockResolvedValue({ user, redirect: '' }); + oauthService.provisionUser.mockResolvedValue(user); accountService.findByUserId.mockResolvedValue(null); }; diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 599744cc1a7..e5bc6f942f8 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,14 +1,13 @@ +import { AccountService } from '@modules/account/services/account.service'; +import { AccountDto } from '@modules/account/services/dto'; +import { OAuthTokenDto, OAuthService } from '@modules/oauth'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; -import { OAuthTokenDto } from '@modules/oauth'; -import { OAuthService } from '@modules/oauth/service/oauth.service'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; -import { SchoolInMigrationError } from '../errors/school-in-migration.error'; import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { SchoolInMigrationLoggableException } from '../loggable'; import { CurrentUserMapper } from '../mapper'; @Injectable() @@ -22,14 +21,10 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(systemId, redirectUri, code); - const { user }: { user?: UserDO; redirect: string } = await this.oauthService.provisionUser( - systemId, - tokenDto.idToken, - tokenDto.accessToken - ); + const user: UserDO | null = await this.oauthService.provisionUser(systemId, tokenDto.idToken, tokenDto.accessToken); if (!user || !user.id) { - throw new SchoolInMigrationError(); + throw new SchoolInMigrationLoggableException(); } const account: AccountDto | null = await this.accountService.findByUserId(user.id); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..bd49bb841e6 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestInputBuilder } from './deletion-request-input.builder'; + +describe(DeletionRequestInputBuilder.name, () => { + describe(DeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'school'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 43200; + + const expectedOutput: DeletionRequestInput = { + targetRef: { + domain: targetRefDomain, + id: targetRefId, + }, + deleteInMinutes, + }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = DeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts new file mode 100644 index 00000000000..28091418065 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts @@ -0,0 +1,11 @@ +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +export class DeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes?: number): DeletionRequestInput { + return { + targetRef: DeletionRequestTargetRefInputBuilder.build(targetRefDomain, targetRefId), + deleteInMinutes, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..399821f33ff --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestOutput } from '../interface'; +import { DeletionRequestOutputBuilder } from './deletion-request-output.builder'; + +describe(DeletionRequestOutputBuilder.name, () => { + describe(DeletionRequestOutputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: DeletionRequestOutput = { + requestId, + deletionPlannedAt, + }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = DeletionRequestOutputBuilder.build(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts new file mode 100644 index 00000000000..9192c1a47ce --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts @@ -0,0 +1,10 @@ +import { DeletionRequestOutput } from '../interface'; + +export class DeletionRequestOutputBuilder { + static build(requestId: string, deletionPlannedAt: Date): DeletionRequestOutput { + return { + requestId, + deletionPlannedAt, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts new file mode 100644 index 00000000000..74b0631e49d --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestTargetRefInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +describe(DeletionRequestTargetRefInputBuilder.name, () => { + describe(DeletionRequestTargetRefInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const domain = 'user'; + const id = new ObjectId().toHexString(); + + const expectedOutput: DeletionRequestTargetRefInput = { domain, id }; + + return { domain, id, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { domain, id, expectedOutput } = setup(); + + const output = DeletionRequestTargetRefInputBuilder.build(domain, id); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts new file mode 100644 index 00000000000..ed3c0219993 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts @@ -0,0 +1,7 @@ +import { DeletionRequestTargetRefInput } from '../interface'; + +export class DeletionRequestTargetRefInputBuilder { + static build(domain: string, id: string): DeletionRequestTargetRefInput { + return { domain, id }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/index.ts b/apps/server/src/modules/deletion/client/builder/index.ts new file mode 100644 index 00000000000..85644a6b2ee --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/index.ts @@ -0,0 +1,3 @@ +export * from './deletion-request-target-ref-input.builder'; +export * from './deletion-request-input.builder'; +export * from './deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts new file mode 100644 index 00000000000..a3cae21e425 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts @@ -0,0 +1,41 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; +import { getDeletionClientConfig } from './deletion-client.config'; + +describe(getDeletionClientConfig.name, () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + afterEach(() => { + Configuration.reset(configBefore); + }); + + describe('when called', () => { + const setup = () => { + const baseUrl = 'http://api-admin:4030'; + const apiKey = '652559c2-93da-42ad-94e1-640e3afbaca0'; + + Configuration.set('ADMIN_API_CLIENT__BASE_URL', baseUrl); + Configuration.set('ADMIN_API_CLIENT__API_KEY', apiKey); + + const expectedConfig: DeletionClientConfig = { + ADMIN_API_CLIENT_BASE_URL: baseUrl, + ADMIN_API_CLIENT_API_KEY: apiKey, + }; + + return { expectedConfig }; + }; + + it('should return config with proper values', () => { + const { expectedConfig } = setup(); + + const config = getDeletionClientConfig(); + + expect(config).toEqual(expectedConfig); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.ts b/apps/server/src/modules/deletion/client/deletion-client.config.ts new file mode 100644 index 00000000000..db5bf7ff226 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.ts @@ -0,0 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; + +export const getDeletionClientConfig = (): DeletionClientConfig => { + return { + ADMIN_API_CLIENT_BASE_URL: Configuration.get('ADMIN_API_CLIENT__BASE_URL') as string, + ADMIN_API_CLIENT_API_KEY: Configuration.get('ADMIN_API_CLIENT__API_KEY') as string, + }; +}; diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts new file mode 100644 index 00000000000..096b1f9b082 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { axiosResponseFactory } from '@shared/testing'; +import { DeletionRequestInputBuilder, DeletionRequestOutputBuilder } from '.'; +import { DeletionRequestOutput } from './interface'; +import { DeletionClient } from './deletion.client'; + +describe(DeletionClient.name, () => { + let module: TestingModule; + let client: DeletionClient; + let httpService: DeepMocked; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionClient, + { + provide: ConfigService, + useValue: createMock({ + get: jest.fn((key: string) => { + if (key === 'ADMIN_API_CLIENT_BASE_URL') { + return 'http://localhost:4030'; + } + + // Default is for the Admin APIs API Key. + return '6b3df003-61e9-467c-9e6b-579634801896'; + }), + }), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + client = module.get(DeletionClient); + httpService = module.get(HttpService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('queueDeletionRequest', () => { + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '6536ce29b595d7c8e5faf200', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input, output }; + }; + + it('should return proper output', async () => { + const { input, output } = setup(); + + const result = await client.queueDeletionRequest(input); + + expect(result).toEqual(output); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build('', new Date()); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no requestId in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no deletionPlannedAt in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: { + requestId: '6536ce29b595d7c8e5faf200', + }, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts new file mode 100644 index 00000000000..66bb267d070 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -0,0 +1,66 @@ +import { firstValueFrom } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; + +@Injectable() +export class DeletionClient { + private readonly baseUrl: string; + + private readonly apiKey: string; + + private readonly postDeletionRequestsEndpoint: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { + this.baseUrl = this.configService.get('ADMIN_API_CLIENT_BASE_URL'); + this.apiKey = this.configService.get('ADMIN_API_CLIENT_API_KEY'); + + // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. + this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + } + + async queueDeletionRequest(input: DeletionRequestInput): Promise { + const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); + + return firstValueFrom(request) + .then((resp: AxiosResponse) => { + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw new Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + }) + .catch((err: Error) => { + // Throw an error if sending/processing deletion request by the client failed in any way. + throw new Error(`failed to send/process a deletion request: ${err.toString()}`); + }); + } + + private apiKeyHeader() { + return { 'X-Api-Key': this.apiKey }; + } + + private defaultHeaders() { + return { + headers: this.apiKeyHeader(), + }; + } +} diff --git a/apps/server/src/modules/deletion/client/index.ts b/apps/server/src/modules/deletion/client/index.ts new file mode 100644 index 00000000000..fde3db98f3b --- /dev/null +++ b/apps/server/src/modules/deletion/client/index.ts @@ -0,0 +1,3 @@ +export * from './interface'; +export * from './builder'; +export * from './deletion.client'; diff --git a/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts new file mode 100644 index 00000000000..8178515b6d4 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionClientConfig { + ADMIN_API_CLIENT_BASE_URL: string; + ADMIN_API_CLIENT_API_KEY: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts new file mode 100644 index 00000000000..4879ce4d972 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts @@ -0,0 +1,6 @@ +import { DeletionRequestTargetRefInput } from './deletion-request-target-ref-input.interface'; + +export interface DeletionRequestInput { + targetRef: DeletionRequestTargetRefInput; + deleteInMinutes?: number; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts new file mode 100644 index 00000000000..b61a372d5a5 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestOutput { + requestId: string; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts new file mode 100644 index 00000000000..603bb0c13ec --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestTargetRefInput { + domain: string; + id: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/index.ts b/apps/server/src/modules/deletion/client/interface/index.ts new file mode 100644 index 00000000000..38f0f639731 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/index.ts @@ -0,0 +1,4 @@ +export * from './deletion-client-config.interface'; +export * from './deletion-request-target-ref-input.interface'; +export * from './deletion-request-input.interface'; +export * from './deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts new file mode 100644 index 00000000000..12fd0997ebe --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts new file mode 100644 index 00000000000..5c83defdd1e --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts @@ -0,0 +1,43 @@ +import { PushDeletionRequestsOptions } from '../interface'; +import { PushDeleteRequestsOptionsBuilder } from './push-delete-requests-options.builder'; + +describe(PushDeleteRequestsOptionsBuilder.name, () => { + describe(PushDeleteRequestsOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const expectedOutput: PushDeletionRequestsOptions = { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + expectedOutput, + }; + }; + + it('should return valid object with expected values', () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, expectedOutput } = setup(); + + const output = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts new file mode 100644 index 00000000000..f8ceae9263d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts @@ -0,0 +1,17 @@ +import { PushDeletionRequestsOptions } from '../interface'; + +export class PushDeleteRequestsOptionsBuilder { + static build( + refsFilePath: string, + targetRefDomain: string, + deleteInMinutes: number, + callsDelayMs: number + ): PushDeletionRequestsOptions { + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts new file mode 100644 index 00000000000..0585b3631da --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { ConsoleModule } from 'nestjs-console'; +import { ConsoleWriterModule } from '@infra/console'; +import { createConfigModuleOptions } from '@src/config'; +import { DeletionClient } from '../client'; +import { getDeletionClientConfig } from '../client/deletion-client.config'; +import { BatchDeletionService } from '../services'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; + +@Module({ + imports: [ + ConsoleModule, + ConsoleWriterModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), + ], + providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], +}) +export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts new file mode 100644 index 00000000000..61f9cf0ff53 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@infra/console'; +import { createMock } from '@golevelup/ts-jest'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; +import { PushDeleteRequestsOptionsBuilder } from './builder'; + +describe(DeletionQueueConsole.name, () => { + let module: TestingModule; + let console: DeletionQueueConsole; + let batchDeletionUc: BatchDeletionUc; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionQueueConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: BatchDeletionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionQueueConsole); + batchDeletionUc = module.get(BatchDeletionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('pushDeletionRequests', () => { + describe('when called with valid options', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const options = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + options, + }; + }; + + it(`should call ${BatchDeletionUc.name} with proper arguments`, async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, options } = setup(); + + const spy = jest.spyOn(batchDeletionUc, 'deleteRefsFromTxtFile'); + + await console.pushDeletionRequests(options); + + expect(spy).toBeCalledWith(refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.ts new file mode 100644 index 00000000000..c8b133dba84 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.ts @@ -0,0 +1,46 @@ +import { Console, Command } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { BatchDeletionUc } from '../uc'; +import { PushDeletionRequestsOptions } from './interface'; + +@Console({ command: 'queue', description: 'Console providing an access to the deletion queue.' }) +export class DeletionQueueConsole { + constructor(private consoleWriter: ConsoleWriterService, private batchDeletionUc: BatchDeletionUc) {} + + @Command({ + command: 'push', + description: 'Push new deletion requests to the deletion queue.', + options: [ + { + flags: '-rfp, --refsFilePath ', + description: 'Path of the file containing all the references to the data that should be deleted.', + required: true, + }, + { + flags: '-trd, --targetRefDomain ', + description: 'Name of the target ref domain.', + required: false, + }, + { + flags: '-dim, --deleteInMinutes ', + description: 'Number of minutes after which the data deletion process should begin.', + required: false, + }, + { + flags: '-cdm, --callsDelayMs ', + description: 'Delay between all the performed client calls, in milliseconds.', + required: false, + }, + ], + }) + async pushDeletionRequests(options: PushDeletionRequestsOptions): Promise { + const summary = await this.batchDeletionUc.deleteRefsFromTxtFile( + options.refsFilePath, + options.targetRefDomain, + options.deleteInMinutes ? Number(options.deleteInMinutes) : undefined, + options.callsDelayMs ? Number(options.callsDelayMs) : undefined + ); + + this.consoleWriter.info(JSON.stringify(summary)); + } +} diff --git a/apps/server/src/modules/deletion/console/index.ts b/apps/server/src/modules/deletion/console/index.ts new file mode 100644 index 00000000000..db47bd8c99f --- /dev/null +++ b/apps/server/src/modules/deletion/console/index.ts @@ -0,0 +1 @@ +export * from './deletion-console.module'; diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts new file mode 100644 index 00000000000..2fcb281430f --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.interface'; diff --git a/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts new file mode 100644 index 00000000000..a54f652cf94 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts @@ -0,0 +1,6 @@ +export interface PushDeletionRequestsOptions { + refsFilePath: string; + targetRefDomain: string; + deleteInMinutes: number; + callsDelayMs: number; +} diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index bd89c1e8d84..793f306e7a0 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,2 +1,4 @@ export * from './deletion.module'; export * from './services'; +export * from './client'; +export * from './console'; diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts new file mode 100644 index 00000000000..640e290af1a --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts @@ -0,0 +1,97 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient, DeletionRequestOutput, DeletionRequestOutputBuilder } from '../client'; +import { QueueDeletionRequestInputBuilder, QueueDeletionRequestOutputBuilder } from './builder'; +import { BatchDeletionService } from './batch-deletion.service'; + +describe(BatchDeletionService.name, () => { + let module: TestingModule; + let service: BatchDeletionService; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionService, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(BatchDeletionService); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('queueDeletionRequests', () => { + describe('when called with valid inputs array and a requested delay between the client calls', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const queueDeletionRequestOutput: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + requestId, + deletionPlannedAt + ); + + deletionClient.queueDeletionRequest.mockResolvedValueOnce(queueDeletionRequestOutput); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with successful status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs, 1); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const error = new Error('connection error'); + + deletionClient.queueDeletionRequest.mockRejectedValueOnce(error); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildError(error); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with failure status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.ts new file mode 100644 index 00000000000..d8c14f315a0 --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { QueueDeletionRequestOutputBuilder } from './builder'; +import { DeletionClient, DeletionRequestInputBuilder } from '../client'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from './interface'; + +@Injectable() +export class BatchDeletionService { + constructor(private readonly deletionClient: DeletionClient) {} + + async queueDeletionRequests( + inputs: QueueDeletionRequestInput[], + callsDelayMilliseconds?: number + ): Promise { + const outputs: QueueDeletionRequestOutput[] = []; + + // For every provided deletion request input, try to queue it via deletion client. + // In any case, add the result of the trial to the outputs - it will be either a valid + // response in a form of a requestId + deletionPlannedAt values pair or some error + // returned from the client. In any case, every input should be processed. + for (const input of inputs) { + const deletionRequestInput = DeletionRequestInputBuilder.build( + input.targetRefDomain, + input.targetRefId, + input.deleteInMinutes + ); + + try { + // eslint-disable-next-line no-await-in-loop + const deletionRequestOutput = await this.deletionClient.queueDeletionRequest(deletionRequestInput); + + // In case of a successful client response, add the + // requestId + deletionPlannedAt values pair to the outputs. + outputs.push( + QueueDeletionRequestOutputBuilder.buildSuccess( + deletionRequestOutput.requestId, + deletionRequestOutput.deletionPlannedAt + ) + ); + } catch (err) { + // In case of a failure client response, add the full error message to the outputs. + outputs.push(QueueDeletionRequestOutputBuilder.buildError(err as Error)); + } + + // If any delay between the client calls has been requested, "sleep" for the specified amount of time. + if (callsDelayMilliseconds && callsDelayMilliseconds > 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, callsDelayMilliseconds); + }); + } + } + + return outputs; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/index.ts b/apps/server/src/modules/deletion/services/builder/index.ts new file mode 100644 index 00000000000..acd85a37989 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.builder'; +export * from './queue-deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..e5d87858156 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestInput } from '../interface'; +import { QueueDeletionRequestInputBuilder } from './queue-deletion-request-input.builder'; + +describe(QueueDeletionRequestInputBuilder.name, () => { + describe(QueueDeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'user'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 60; + + const expectedOutput: QueueDeletionRequestInput = { targetRefDomain, targetRefId, deleteInMinutes }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts new file mode 100644 index 00000000000..a7fff2152b9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts @@ -0,0 +1,7 @@ +import { QueueDeletionRequestInput } from '../interface'; + +export class QueueDeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes: number): QueueDeletionRequestInput { + return { targetRefDomain, targetRefId, deleteInMinutes }; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..cd835a9cf4a --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts @@ -0,0 +1,46 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestOutput } from '../interface'; +import { QueueDeletionRequestOutputBuilder } from './queue-deletion-request-output.builder'; + +describe(QueueDeletionRequestOutputBuilder.name, () => { + describe(QueueDeletionRequestOutputBuilder.buildSuccess.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: QueueDeletionRequestOutput = { requestId, deletionPlannedAt }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(QueueDeletionRequestOutputBuilder.buildError.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: QueueDeletionRequestOutput = { error: error.toString() }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildError(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts new file mode 100644 index 00000000000..c44e503cbaf --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts @@ -0,0 +1,29 @@ +import { QueueDeletionRequestOutput } from '../interface'; + +export class QueueDeletionRequestOutputBuilder { + private static build(requestId?: string, deletionPlannedAt?: Date, error?: string): QueueDeletionRequestOutput { + const output: QueueDeletionRequestOutput = {}; + + if (requestId) { + output.requestId = requestId; + } + + if (deletionPlannedAt) { + output.deletionPlannedAt = deletionPlannedAt; + } + + if (error) { + output.error = error.toString(); + } + + return output; + } + + static buildSuccess(requestId: string, deletionPlannedAt: Date): QueueDeletionRequestOutput { + return this.build(requestId, deletionPlannedAt); + } + + static buildError(err: Error): QueueDeletionRequestOutput { + return this.build(undefined, undefined, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts index 9661354718c..52e0b6ba22d 100644 --- a/apps/server/src/modules/deletion/services/index.ts +++ b/apps/server/src/modules/deletion/services/index.ts @@ -1 +1,5 @@ +export * from './interface'; +export * from './builder'; +export * from './references.service'; +export * from './batch-deletion.service'; export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/services/interface/index.ts b/apps/server/src/modules/deletion/services/interface/index.ts new file mode 100644 index 00000000000..8a455440798 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.interface'; +export * from './queue-deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts new file mode 100644 index 00000000000..b421943bce9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestInput { + targetRefDomain: string; + targetRefId: string; + deleteInMinutes: number; +} diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts new file mode 100644 index 00000000000..375ff811857 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestOutput { + requestId?: string; + deletionPlannedAt?: Date; + error?: string; +} diff --git a/apps/server/src/modules/deletion/services/references.service.spec.ts b/apps/server/src/modules/deletion/services/references.service.spec.ts new file mode 100644 index 00000000000..26eb6e0c8d7 --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.spec.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import { ReferencesService } from './references.service'; + +describe(ReferencesService.name, () => { + describe(ReferencesService.loadFromTxtFile.name, () => { + const setup = (mockedFileContent: string) => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockedFileContent); + }; + + describe('when passed a completely empty file (without any content)', () => { + it('should return an empty references array', () => { + setup(''); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file without any references (just some empty lines)', () => { + it('should return an empty references array', () => { + setup('\n\n \n \n\n\n \n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file with 3 references on a few separate lines', () => { + describe('split with CRs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r653fd3b784ca851b17e9857a\r653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with LFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\n653fd3b784ca851b17e9857a\n653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with CRLFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r\n653fd3b784ca851b17e9857a\r\n653fd3b784ca851b17e9857b\r\n\r\n\r\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/references.service.ts b/apps/server/src/modules/deletion/services/references.service.ts new file mode 100644 index 00000000000..991c36c02af --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; + +export class ReferencesService { + static loadFromTxtFile(filePath: string): string[] { + let fileContent = fs.readFileSync(filePath).toString(); + + // Replace all the CRLF occurrences with just a LF. + fileContent = fileContent.replace(/\r\n?/g, '\n'); + + // Split the whole file content by a line feed (LF) char (\n). + const fileLines = fileContent.split('\n'); + + const references: string[] = []; + + // Iterate over all the file lines and if it contains a valid id (which is + // basically any non-empty string), add it to the loaded references array. + fileLines.forEach((fileLine) => { + const reference = fileLine.trim(); + + if (reference && reference.length > 0) { + references.push(reference); + } + }); + + return references; + } +} diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts new file mode 100644 index 00000000000..7292f36efb0 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts @@ -0,0 +1,195 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + BatchDeletionService, + QueueDeletionRequestInputBuilder, + QueueDeletionRequestOutput, + QueueDeletionRequestOutputBuilder, + ReferencesService, +} from '../services'; +import { BatchDeletionSummaryDetail, BatchDeletionSummaryOverallStatus } from './interface'; +import { BatchDeletionSummaryDetailBuilder } from './builder'; +import { BatchDeletionUc } from './batch-deletion.uc'; + +describe(BatchDeletionUc.name, () => { + let module: TestingModule; + let uc: BatchDeletionUc; + let batchDeletionService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionUc, + { + provide: BatchDeletionService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(BatchDeletionUc); + batchDeletionService = module.get(BatchDeletionService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('deleteRefsFromTxtFile', () => { + describe('when called with valid arguments', () => { + describe('when batch deletion service returns an expected amount of outputs', () => { + describe('when only successful executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.SUCCESS, + successCount: 3, + failureCount: 0, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and a successful overall status', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + + describe('when both successful and failed executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs = [ + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + QueueDeletionRequestOutputBuilder.buildError(new Error('some error occurred...')), + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + ]; + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 2, + failureCount: 1, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and failures', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + }); + + describe('when batch deletion service returns an invalid amount of outputs', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount - 1; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce(outputs); + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath }; + }; + + it('should throw an error', async () => { + const { refsFilePath } = setup(); + + const func = () => uc.deleteRefsFromTxtFile(refsFilePath); + + await expect(func()).rejects.toThrow(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts new file mode 100644 index 00000000000..258b1b53f65 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { BatchDeletionSummaryBuilder, BatchDeletionSummaryDetailBuilder } from './builder'; +import { + ReferencesService, + BatchDeletionService, + QueueDeletionRequestInput, + QueueDeletionRequestInputBuilder, +} from '../services'; +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from './interface'; + +@Injectable() +export class BatchDeletionUc { + constructor(private readonly batchDeletionService: BatchDeletionService) {} + + async deleteRefsFromTxtFile( + refsFilePath: string, + targetRefDomain = 'user', + deleteInMinutes = 43200, // 43200 minutes = 720 hours = 30 days + callsDelayMilliseconds?: number + ): Promise { + // First, load all the references from the provided text file (with given path). + const refsFromTxtFile = ReferencesService.loadFromTxtFile(refsFilePath); + + const inputs: QueueDeletionRequestInput[] = []; + + // For each reference found in a given file, add it to the inputs + // array (with added targetRefDomain and deleteInMinutes fields). + refsFromTxtFile.forEach((ref) => + inputs.push(QueueDeletionRequestInputBuilder.build(targetRefDomain, ref, deleteInMinutes)) + ); + + // Measure the overall queueing execution time by setting the start... + const startTime = performance.now(); + + const outputs = await this.batchDeletionService.queueDeletionRequests(inputs, callsDelayMilliseconds); + + // ...and end timestamps before and after the batch deletion service method execution. + const endTime = performance.now(); + + // Throw an error if the returned outputs number doesn't match the returned inputs number. + if (outputs.length !== inputs.length) { + throw new Error( + 'invalid result from the batch deletion service - expected to ' + + 'receive the same amount of outputs as the provided inputs, ' + + `instead received ${outputs.length} outputs for ${inputs.length} inputs` + ); + } + + const summary: BatchDeletionSummary = BatchDeletionSummaryBuilder.build(endTime - startTime); + + // Go through every received output and, in case of an error presence increase + // a failure count or, in case of no error, increase a success count. + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].error) { + summary.failureCount += 1; + } else { + summary.successCount += 1; + } + + // Also add all the processed inputs and outputs details to the overall summary. + summary.details.push(BatchDeletionSummaryDetailBuilder.build(inputs[i], outputs[i])); + } + + // If no failure has been spotted, assume an overall success. + if (summary.failureCount === 0) { + summary.overallStatus = BatchDeletionSummaryOverallStatus.SUCCESS; + } + + return summary; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts new file mode 100644 index 00000000000..43ea82e86d5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BatchDeletionSummaryDetail } from '..'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetailBuilder } from './batch-deletion-summary-detail.builder'; + +describe(BatchDeletionSummaryDetailBuilder.name, () => { + describe(BatchDeletionSummaryDetailBuilder.build.name, () => { + describe('when called with proper arguments for', () => { + describe('a successful output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + requestId: new ObjectId().toHexString(), + deletionPlannedAt: new Date(), + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + + describe('an error output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + error: 'some error occurred...', + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts new file mode 100644 index 00000000000..9ebbce66171 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts @@ -0,0 +1,8 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetail } from '../interface'; + +export class BatchDeletionSummaryDetailBuilder { + static build(input: QueueDeletionRequestInput, output: QueueDeletionRequestOutput): BatchDeletionSummaryDetail { + return { input, output }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts new file mode 100644 index 00000000000..a2b534602d4 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts @@ -0,0 +1,30 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; +import { BatchDeletionSummaryBuilder } from './batch-deletion-summary.builder'; + +describe(BatchDeletionSummaryBuilder.name, () => { + describe(BatchDeletionSummaryBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const executionTimeMilliseconds = 142; + + const expectedOutput: BatchDeletionSummary = { + executionTimeMilliseconds: 142, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + + return { executionTimeMilliseconds, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { executionTimeMilliseconds, expectedOutput } = setup(); + + const output = BatchDeletionSummaryBuilder.build(executionTimeMilliseconds); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts new file mode 100644 index 00000000000..57fa2bcccd9 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts @@ -0,0 +1,13 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; + +export class BatchDeletionSummaryBuilder { + static build(executionTimeMilliseconds: number): BatchDeletionSummary { + return { + executionTimeMilliseconds, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/index.ts b/apps/server/src/modules/deletion/uc/builder/index.ts new file mode 100644 index 00000000000..46733980f94 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/index.ts @@ -0,0 +1,2 @@ +export * from './batch-deletion-summary-detail.builder'; +export * from './batch-deletion-summary.builder'; diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts new file mode 100644 index 00000000000..cf74de969e5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -0,0 +1,2 @@ +export * from './interface'; +export * from './batch-deletion.uc'; diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts new file mode 100644 index 00000000000..4fe99c13fad --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts @@ -0,0 +1,6 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; + +export interface BatchDeletionSummaryDetail { + input: QueueDeletionRequestInput; + output: QueueDeletionRequestOutput; +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts new file mode 100644 index 00000000000..4ae91bdf70e --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts @@ -0,0 +1,4 @@ +export const enum BatchDeletionSummaryOverallStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts new file mode 100644 index 00000000000..ce633e164f1 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts @@ -0,0 +1,9 @@ +import { BatchDeletionSummaryDetail } from './batch-deletion-summary-detail.interface'; + +export interface BatchDeletionSummary { + executionTimeMilliseconds: number; + overallStatus: string; + successCount: number; + failureCount: number; + details: BatchDeletionSummaryDetail[]; +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts index 95786098275..9dcf644f410 100644 --- a/apps/server/src/modules/deletion/uc/interface/index.ts +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -1 +1,4 @@ +export * from './batch-deletion-summary-overall-status.enum'; +export * from './batch-deletion-summary-detail.interface'; +export * from './batch-deletion-summary.interface'; export * from './interfaces'; diff --git a/apps/server/src/modules/legacy-school/controller/dto/index.ts b/apps/server/src/modules/legacy-school/controller/dto/index.ts deleted file mode 100644 index a6e114b1f29..00000000000 --- a/apps/server/src/modules/legacy-school/controller/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './migration.body'; -export * from './migration.response'; -export * from './school.params'; diff --git a/apps/server/src/modules/legacy-school/controller/dto/migration.body.ts b/apps/server/src/modules/legacy-school/controller/dto/migration.body.ts deleted file mode 100644 index b598b78942c..00000000000 --- a/apps/server/src/modules/legacy-school/controller/dto/migration.body.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; - -export class MigrationBody { - @IsBoolean() - @IsOptional() - @ApiProperty({ - description: 'Set if migration is possible in this school', - required: false, - nullable: true, - }) - oauthMigrationPossible?: boolean; - - @IsBoolean() - @IsOptional() - @ApiProperty({ - description: 'Set if migration is mandatory in this school', - required: false, - nullable: true, - }) - oauthMigrationMandatory?: boolean; - - @IsBoolean() - @IsOptional() - @ApiProperty({ - description: 'Set if migration is finished in this school', - required: false, - nullable: true, - }) - oauthMigrationFinished?: boolean; -} diff --git a/apps/server/src/modules/legacy-school/controller/dto/migration.response.ts b/apps/server/src/modules/legacy-school/controller/dto/migration.response.ts deleted file mode 100644 index 7a7aea8445c..00000000000 --- a/apps/server/src/modules/legacy-school/controller/dto/migration.response.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class MigrationResponse { - @ApiPropertyOptional({ - description: 'Date from when Migration is possible', - type: Date, - }) - oauthMigrationPossible?: Date; - - @ApiPropertyOptional({ - description: 'Date from when Migration is mandatory', - type: Date, - }) - oauthMigrationMandatory?: Date; - - @ApiPropertyOptional({ - description: 'Date from when Migration is finished', - type: Date, - }) - oauthMigrationFinished?: Date; - - @ApiPropertyOptional({ - description: 'Date from when Migration is finally finished and cannot be restarted again', - type: Date, - }) - oauthMigrationFinalFinish?: Date; - - @ApiProperty({ - description: 'Enable the Migration', - }) - enableMigrationStart!: boolean; - - constructor(params: MigrationResponse) { - this.oauthMigrationPossible = params.oauthMigrationPossible; - this.oauthMigrationMandatory = params.oauthMigrationMandatory; - this.oauthMigrationFinished = params.oauthMigrationFinished; - this.oauthMigrationFinalFinish = params.oauthMigrationFinalFinish; - this.enableMigrationStart = params.enableMigrationStart; - } -} diff --git a/apps/server/src/modules/legacy-school/controller/dto/school.params.ts b/apps/server/src/modules/legacy-school/controller/dto/school.params.ts deleted file mode 100644 index 248ac3861b5..00000000000 --- a/apps/server/src/modules/legacy-school/controller/dto/school.params.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsMongoId } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class SchoolParams { - @IsMongoId() - @ApiProperty({ - description: 'The id of the school.', - required: true, - nullable: false, - }) - schoolId!: string; -} diff --git a/apps/server/src/modules/legacy-school/controller/legacy-school.controller.spec.ts b/apps/server/src/modules/legacy-school/controller/legacy-school.controller.spec.ts deleted file mode 100644 index 764d71b6abf..00000000000 --- a/apps/server/src/modules/legacy-school/controller/legacy-school.controller.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ICurrentUser } from '@modules/authentication'; -import { MigrationMapper } from '../mapper/migration.mapper'; -import { OauthMigrationDto } from '../uc/dto/oauth-migration.dto'; -import { LegacySchoolUc } from '../uc'; -import { MigrationBody, MigrationResponse, SchoolParams } from './dto'; -import { LegacySchoolController } from './legacy-school.controller'; - -describe('Legacy School Controller', () => { - let module: TestingModule; - let controller: LegacySchoolController; - let schoolUc: DeepMocked; - let mapper: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - controllers: [LegacySchoolController], - providers: [ - { - provide: LegacySchoolUc, - useValue: createMock(), - }, - { - provide: MigrationMapper, - useValue: createMock(), - }, - ], - }).compile(); - controller = module.get(LegacySchoolController); - schoolUc = module.get(LegacySchoolUc); - mapper = module.get(MigrationMapper); - }); - - afterAll(async () => { - await module.close(); - jest.clearAllMocks(); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - const setupBasicData = () => { - const schoolParams: SchoolParams = { schoolId: new ObjectId().toHexString() }; - const testUser: ICurrentUser = { userId: 'testUser' } as ICurrentUser; - - const migrationResp: MigrationResponse = { - oauthMigrationMandatory: new Date(), - oauthMigrationPossible: new Date(), - oauthMigrationFinished: new Date(), - enableMigrationStart: true, - }; - const migrationDto: OauthMigrationDto = new OauthMigrationDto({ - oauthMigrationMandatory: new Date(), - oauthMigrationPossible: new Date(), - oauthMigrationFinished: new Date(), - enableMigrationStart: true, - }); - return { schoolParams, testUser, migrationDto, migrationResp }; - }; - - describe('setMigration', () => { - describe('when migrationflags exist and schoolId and userId are given', () => { - it('should call UC and recieve a response', async () => { - const { schoolParams, testUser, migrationDto, migrationResp } = setupBasicData(); - schoolUc.setMigration.mockResolvedValue(migrationDto); - mapper.mapDtoToResponse.mockReturnValue(migrationResp); - const body: MigrationBody = { oauthMigrationPossible: true, oauthMigrationMandatory: true }; - - const res: MigrationResponse = await controller.setMigration(schoolParams, body, testUser); - - expect(schoolUc.setMigration).toHaveBeenCalled(); - expect(res).toBe(migrationResp); - }); - }); - }); - - describe('getMigration', () => { - describe('when schoolId and UserId are given', () => { - it('should call UC and recieve a response', async () => { - const { schoolParams, testUser, migrationDto, migrationResp } = setupBasicData(); - schoolUc.getMigration.mockResolvedValue(migrationDto); - mapper.mapDtoToResponse.mockReturnValue(migrationResp); - - const res: MigrationResponse = await controller.getMigration(schoolParams, testUser); - - expect(schoolUc.getMigration).toHaveBeenCalled(); - expect(res).toBe(migrationResp); - }); - }); - }); -}); diff --git a/apps/server/src/modules/legacy-school/controller/legacy-school.controller.ts b/apps/server/src/modules/legacy-school/controller/legacy-school.controller.ts deleted file mode 100644 index 58b591faea1..00000000000 --- a/apps/server/src/modules/legacy-school/controller/legacy-school.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; -import { - ApiFoundResponse, - ApiNotFoundResponse, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; -import { ICurrentUser, Authenticate, CurrentUser } from '@modules/authentication'; -import { MigrationMapper } from '../mapper/migration.mapper'; -import { OauthMigrationDto } from '../uc/dto/oauth-migration.dto'; -import { LegacySchoolUc } from '../uc'; -import { MigrationBody, MigrationResponse, SchoolParams } from './dto'; - -/** - * @deprecated because it uses the deprecated LegacySchoolDo. - */ -@ApiTags('School') -@Authenticate('jwt') -@Controller('school') -export class LegacySchoolController { - constructor(private readonly schoolUc: LegacySchoolUc, private readonly migrationMapper: MigrationMapper) {} - - @Put(':schoolId/migration') - @Authenticate('jwt') - @ApiOkResponse({ description: 'New migrationflags set', type: MigrationResponse }) - @ApiUnauthorizedResponse() - async setMigration( - @Param() schoolParams: SchoolParams, - @Body() migrationBody: MigrationBody, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const migrationDto: OauthMigrationDto = await this.schoolUc.setMigration( - schoolParams.schoolId, - !!migrationBody.oauthMigrationPossible, - !!migrationBody.oauthMigrationMandatory, - !!migrationBody.oauthMigrationFinished, - currentUser.userId - ); - - const result: MigrationResponse = this.migrationMapper.mapDtoToResponse(migrationDto); - - return result; - } - - @Get(':schoolId/migration') - @Authenticate('jwt') - @ApiFoundResponse({ description: 'Migrationflags have been found.', type: MigrationResponse }) - @ApiUnauthorizedResponse() - @ApiNotFoundResponse({ description: 'Migrationsflags could not be found for the given school' }) - async getMigration( - @Param() schoolParams: SchoolParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const migrationDto: OauthMigrationDto = await this.schoolUc.getMigration(schoolParams.schoolId, currentUser.userId); - - const result: MigrationResponse = this.migrationMapper.mapDtoToResponse(migrationDto); - - return result; - } -} diff --git a/apps/server/src/modules/legacy-school/legacy-school-api.module.ts b/apps/server/src/modules/legacy-school/legacy-school-api.module.ts deleted file mode 100644 index aaf1f6acad2..00000000000 --- a/apps/server/src/modules/legacy-school/legacy-school-api.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuthorizationModule } from '@modules/authorization'; -import { LoggerModule } from '@src/core/logger'; -import { UserLoginMigrationModule } from '@modules/user-login-migration'; -import { LegacySchoolUc } from './uc'; -import { LegacySchoolModule } from './legacy-school.module'; -import { LegacySchoolController } from './controller/legacy-school.controller'; -import { MigrationMapper } from './mapper/migration.mapper'; - -/** - * @deprecated because it uses the deprecated LegacySchoolDo. - */ -@Module({ - imports: [LegacySchoolModule, AuthorizationModule, LoggerModule, UserLoginMigrationModule], - controllers: [LegacySchoolController], - providers: [LegacySchoolUc, MigrationMapper], -}) -export class LegacySchoolApiModule {} diff --git a/apps/server/src/modules/legacy-school/error/index.ts b/apps/server/src/modules/legacy-school/loggable/index.ts similarity index 100% rename from apps/server/src/modules/legacy-school/error/index.ts rename to apps/server/src/modules/legacy-school/loggable/index.ts diff --git a/apps/server/src/modules/legacy-school/error/school-number-duplicate.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/school-number-duplicate.loggable-exception.spec.ts similarity index 100% rename from apps/server/src/modules/legacy-school/error/school-number-duplicate.loggable-exception.spec.ts rename to apps/server/src/modules/legacy-school/loggable/school-number-duplicate.loggable-exception.spec.ts diff --git a/apps/server/src/modules/legacy-school/error/school-number-duplicate.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/school-number-duplicate.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/legacy-school/error/school-number-duplicate.loggable-exception.ts rename to apps/server/src/modules/legacy-school/loggable/school-number-duplicate.loggable-exception.ts diff --git a/apps/server/src/modules/legacy-school/mapper/migration.mapper.spec.ts b/apps/server/src/modules/legacy-school/mapper/migration.mapper.spec.ts deleted file mode 100644 index cc683d2acaa..00000000000 --- a/apps/server/src/modules/legacy-school/mapper/migration.mapper.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MigrationResponse } from '../controller/dto'; -import { OauthMigrationDto } from '../uc/dto/oauth-migration.dto'; -import { MigrationMapper } from './migration.mapper'; - -describe('MigrationMapper', () => { - let mapper: MigrationMapper; - let module: TestingModule; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [MigrationMapper], - }).compile(); - mapper = module.get(MigrationMapper); - }); - - afterAll(async () => { - await module.close(); - }); - describe('when it maps migration data', () => { - const setup = () => { - const dto: OauthMigrationDto = new OauthMigrationDto({ - oauthMigrationPossible: new Date('2023-01-23T09:34:54.854Z'), - oauthMigrationMandatory: new Date('2023-02-23T09:34:54.854Z'), - oauthMigrationFinished: new Date('2023-03-23T09:34:54.854Z'), - oauthMigrationFinalFinish: new Date('2023-04-23T09:34:54.854Z'), - enableMigrationStart: true, - }); - const response: MigrationResponse = new MigrationResponse({ - oauthMigrationPossible: new Date('2023-01-23T09:34:54.854Z'), - oauthMigrationMandatory: new Date('2023-02-23T09:34:54.854Z'), - oauthMigrationFinished: new Date('2023-03-23T09:34:54.854Z'), - oauthMigrationFinalFinish: new Date('2023-04-23T09:34:54.854Z'), - enableMigrationStart: true, - }); - - return { - dto, - response, - }; - }; - - it('mapToDO', () => { - const { dto, response } = setup(); - - const result: MigrationResponse = mapper.mapDtoToResponse(dto); - - expect(result).toEqual(response); - }); - }); -}); diff --git a/apps/server/src/modules/legacy-school/mapper/migration.mapper.ts b/apps/server/src/modules/legacy-school/mapper/migration.mapper.ts deleted file mode 100644 index 3fd152fbc2b..00000000000 --- a/apps/server/src/modules/legacy-school/mapper/migration.mapper.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { MigrationResponse } from '../controller/dto'; -import { OauthMigrationDto } from '../uc/dto/oauth-migration.dto'; - -@Injectable() -export class MigrationMapper { - public mapDtoToResponse(dto: OauthMigrationDto): MigrationResponse { - const response: MigrationResponse = new MigrationResponse({ - oauthMigrationPossible: dto.oauthMigrationPossible, - oauthMigrationMandatory: dto.oauthMigrationMandatory, - oauthMigrationFinished: dto.oauthMigrationFinished, - oauthMigrationFinalFinish: dto.oauthMigrationFinalFinish, - enableMigrationStart: dto.enableMigrationStart, - }); - - return response; - } -} diff --git a/apps/server/src/modules/legacy-school/service/validation/school-validation.service.spec.ts b/apps/server/src/modules/legacy-school/service/validation/school-validation.service.spec.ts index aa01608fb9f..065a65fe446 100644 --- a/apps/server/src/modules/legacy-school/service/validation/school-validation.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/validation/school-validation.service.spec.ts @@ -3,10 +3,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain'; import { LegacySchoolRepo } from '@shared/repo'; import { legacySchoolDoFactory } from '@shared/testing'; -import { SchoolNumberDuplicateLoggableException } from '../../error'; +import { SchoolNumberDuplicateLoggableException } from '../../loggable'; import { SchoolValidationService } from './school-validation.service'; -describe('SchoolValidationService', () => { +describe(SchoolValidationService.name, () => { let module: TestingModule; let service: SchoolValidationService; diff --git a/apps/server/src/modules/legacy-school/service/validation/school-validation.service.ts b/apps/server/src/modules/legacy-school/service/validation/school-validation.service.ts index 044e0473ef3..e934b770407 100644 --- a/apps/server/src/modules/legacy-school/service/validation/school-validation.service.ts +++ b/apps/server/src/modules/legacy-school/service/validation/school-validation.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { LegacySchoolDo } from '@shared/domain'; import { LegacySchoolRepo } from '@shared/repo'; -import { SchoolNumberDuplicateLoggableException } from '../../error'; +import { SchoolNumberDuplicateLoggableException } from '../../loggable'; @Injectable() export class SchoolValidationService { diff --git a/apps/server/src/modules/legacy-school/uc/dto/oauth-migration.dto.ts b/apps/server/src/modules/legacy-school/uc/dto/oauth-migration.dto.ts deleted file mode 100644 index c88bcab9ac9..00000000000 --- a/apps/server/src/modules/legacy-school/uc/dto/oauth-migration.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class OauthMigrationDto { - oauthMigrationPossible?: Date; - - oauthMigrationMandatory?: Date; - - oauthMigrationFinished?: Date; - - oauthMigrationFinalFinish?: Date; - - enableMigrationStart!: boolean; - - constructor(params: OauthMigrationDto) { - this.oauthMigrationPossible = params.oauthMigrationPossible; - this.oauthMigrationMandatory = params.oauthMigrationMandatory; - this.oauthMigrationFinished = params.oauthMigrationFinished; - this.oauthMigrationFinalFinish = params.oauthMigrationFinalFinish; - this.enableMigrationStart = params.enableMigrationStart; - } -} diff --git a/apps/server/src/modules/legacy-school/uc/index.ts b/apps/server/src/modules/legacy-school/uc/index.ts deleted file mode 100644 index 97a341c0458..00000000000 --- a/apps/server/src/modules/legacy-school/uc/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './legacy-school.uc'; diff --git a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts deleted file mode 100644 index 138bcd81a0a..00000000000 --- a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { UnprocessableEntityException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain'; -import { legacySchoolDoFactory, userLoginMigrationDOFactory } from '@shared/testing/factory'; -import { AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school/service'; -import { LegacySchoolUc } from '@modules/legacy-school/uc'; -import { - SchoolMigrationService, - UserLoginMigrationRevertService, - UserLoginMigrationService, -} from '@modules/user-login-migration'; -import { OauthMigrationDto } from './dto/oauth-migration.dto'; - -describe('LegacySchoolUc', () => { - let module: TestingModule; - let schoolUc: LegacySchoolUc; - - let schoolService: DeepMocked; - let authService: DeepMocked; - let schoolMigrationService: DeepMocked; - let userLoginMigrationService: DeepMocked; - let userLoginMigrationRevertService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - LegacySchoolUc, - { - provide: LegacySchoolService, - useValue: createMock(), - }, - { - provide: AuthorizationService, - useValue: createMock(), - }, - { - provide: SchoolMigrationService, - useValue: createMock(), - }, - { - provide: UserLoginMigrationService, - useValue: createMock(), - }, - { - provide: UserLoginMigrationRevertService, - useValue: createMock(), - }, - ], - }).compile(); - - schoolService = module.get(LegacySchoolService); - authService = module.get(AuthorizationService); - schoolUc = module.get(LegacySchoolUc); - schoolMigrationService = module.get(SchoolMigrationService); - userLoginMigrationService = module.get(UserLoginMigrationService); - userLoginMigrationRevertService = module.get(UserLoginMigrationRevertService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - // Tests with case of authService.checkPermission.mockImplementation(() => throw new ForbiddenException()); - // are missed for both methodes - - describe('setMigration is called', () => { - describe('when first starting the migration', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); - authService.checkPermission.mockReturnValueOnce(); - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationService.setMigration.mockResolvedValue(userLoginMigration); - }; - - it('should return the migration dto', async () => { - setup(); - - const result: OauthMigrationDto = await schoolUc.setMigration('schoolId', true, false, false, 'userId'); - - expect(result).toEqual({ - oauthMigrationPossible: new Date('2023-05-02'), - enableMigrationStart: true, - }); - }); - }); - - describe('when closing a migration', () => { - describe('when there were migrated users', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ - closedAt: undefined, - }); - const updatedUserLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ - targetSystemId: userLoginMigration.targetSystemId, - closedAt: new Date(2023, 5), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermission.mockReturnValueOnce(); - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); - schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(true); - - return { - updatedUserLoginMigration, - }; - }; - - it('should call schoolMigrationService.markUnmigratedUsersAsOutdated', async () => { - const { updatedUserLoginMigration } = setup(); - - await schoolUc.setMigration(updatedUserLoginMigration.schoolId, true, false, true, 'userId'); - - expect(schoolMigrationService.markUnmigratedUsersAsOutdated).toHaveBeenCalled(); - }); - }); - - describe('when there were no users migrated', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ - closedAt: undefined, - }); - const updatedUserLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ - targetSystemId: userLoginMigration.targetSystemId, - closedAt: new Date(2023, 5), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermission.mockReturnValueOnce(); - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); - schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); - - return { - updatedUserLoginMigration, - }; - }; - - it('should call userLoginMigrationRevertService.revertUserLoginMigration', async () => { - const { updatedUserLoginMigration } = setup(); - - await schoolUc.setMigration(updatedUserLoginMigration.schoolId, true, false, true, 'userId'); - - expect(userLoginMigrationRevertService.revertUserLoginMigration).toHaveBeenCalledWith( - updatedUserLoginMigration - ); - }); - }); - }); - - describe('when restarting a migration', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - closedAt: new Date('2023-05-02'), - finishedAt: new Date('2023-05-02'), - }); - const updatedUserLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermission.mockReturnValueOnce(); - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); - }; - - it('should call schoolMigrationService.unmarkOutdatedUsers', async () => { - setup(); - - await schoolUc.setMigration('schoolId', true, false, false, 'userId'); - - expect(schoolMigrationService.unmarkOutdatedUsers).toHaveBeenCalled(); - }); - }); - - describe('when trying to start a finished migration after the grace period', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - closedAt: new Date('2023-05-02'), - finishedAt: new Date('2023-05-02'), - }); - const updatedUserLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermission.mockReturnValueOnce(); - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); - schoolMigrationService.validateGracePeriod.mockImplementation(() => { - throw new UnprocessableEntityException(); - }); - }; - - it('should throw an error', async () => { - setup(); - - const func = async () => schoolUc.setMigration('schoolId', true, false, false, 'userId'); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); - }); - - describe('getMigration is called', () => { - describe('when the school has a migration', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const userLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', - startedAt: new Date('2023-05-02'), - mandatorySince: new Date('2023-05-02'), - closedAt: new Date('2023-05-02'), - finishedAt: new Date('2023-05-02'), - }); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - schoolService.getSchoolById.mockResolvedValue(school); - authService.checkPermission.mockReturnValueOnce(); - }; - - it('should return a migration', async () => { - setup(); - - const result: OauthMigrationDto = await schoolUc.getMigration('schoolId', 'userId'); - - expect(result).toEqual({ - oauthMigrationPossible: undefined, - oauthMigrationMandatory: new Date('2023-05-02'), - oauthMigrationFinished: new Date('2023-05-02'), - oauthMigrationFinalFinish: new Date('2023-05-02'), - enableMigrationStart: true, - }); - }); - }); - - describe('when the school has no migration', () => { - const setup = () => { - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); - schoolService.getSchoolById.mockResolvedValue(school); - authService.checkPermission.mockReturnValueOnce(); - }; - - it('should return no migration information', async () => { - setup(); - - const result: OauthMigrationDto = await schoolUc.getMigration('schoolId', 'userId'); - - expect(result).toEqual({ - enableMigrationStart: true, - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts deleted file mode 100644 index 50fd7faf5a5..00000000000 --- a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { Permission, LegacySchoolDo, UserLoginMigrationDO, User } from '@shared/domain'; -import { - SchoolMigrationService, - UserLoginMigrationRevertService, - UserLoginMigrationService, -} from '@modules/user-login-migration'; -import { LegacySchoolService } from '../service'; -import { OauthMigrationDto } from './dto/oauth-migration.dto'; - -/** - * @deprecated because it uses the deprecated LegacySchoolDo. - */ -@Injectable() -export class LegacySchoolUc { - constructor( - private readonly schoolService: LegacySchoolService, - private readonly authService: AuthorizationService, - private readonly schoolMigrationService: SchoolMigrationService, - private readonly userLoginMigrationService: UserLoginMigrationService, - private readonly userLoginMigrationRevertService: UserLoginMigrationRevertService - ) {} - - // TODO: https://ticketsystem.dbildungscloud.de/browse/N21-673 Refactor this and split it up - async setMigration( - schoolId: string, - oauthMigrationPossible: boolean, - oauthMigrationMandatory: boolean, - oauthMigrationFinished: boolean, - userId: string - ): Promise { - const [authorizableUser, school]: [User, LegacySchoolDo] = await Promise.all([ - this.authService.getUserWithPermissions(userId), - this.schoolService.getSchoolById(schoolId), - ]); - - this.checkSchoolAuthorization(authorizableUser, school); - - const existingUserLoginMigration: UserLoginMigrationDO | null = - await this.userLoginMigrationService.findMigrationBySchool(schoolId); - - if (existingUserLoginMigration) { - this.schoolMigrationService.validateGracePeriod(existingUserLoginMigration); - } - - const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.setMigration( - schoolId, - oauthMigrationPossible, - oauthMigrationMandatory, - oauthMigrationFinished - ); - - if (!existingUserLoginMigration?.closedAt && updatedUserLoginMigration.closedAt) { - const hasSchoolMigratedUser = await this.schoolMigrationService.hasSchoolMigratedUser(schoolId); - - if (!hasSchoolMigratedUser) { - await this.userLoginMigrationRevertService.revertUserLoginMigration(updatedUserLoginMigration); - } else { - await this.schoolMigrationService.markUnmigratedUsersAsOutdated(schoolId); - } - } else if (existingUserLoginMigration?.closedAt && !updatedUserLoginMigration.closedAt) { - await this.schoolMigrationService.unmarkOutdatedUsers(schoolId); - } - - const migrationDto: OauthMigrationDto = new OauthMigrationDto({ - oauthMigrationPossible: !updatedUserLoginMigration.closedAt ? updatedUserLoginMigration.startedAt : undefined, - oauthMigrationMandatory: updatedUserLoginMigration.mandatorySince, - oauthMigrationFinished: updatedUserLoginMigration.closedAt, - oauthMigrationFinalFinish: updatedUserLoginMigration.finishedAt, - enableMigrationStart: !!school.officialSchoolNumber, - }); - - return migrationDto; - } - - async getMigration(schoolId: string, userId: string): Promise { - const [authorizableUser, school]: [User, LegacySchoolDo] = await Promise.all([ - this.authService.getUserWithPermissions(userId), - this.schoolService.getSchoolById(schoolId), - ]); - - this.checkSchoolAuthorization(authorizableUser, school); - - const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( - schoolId - ); - - const migrationDto: OauthMigrationDto = new OauthMigrationDto({ - oauthMigrationPossible: - userLoginMigration && !userLoginMigration.closedAt ? userLoginMigration.startedAt : undefined, - oauthMigrationMandatory: userLoginMigration ? userLoginMigration.mandatorySince : undefined, - oauthMigrationFinished: userLoginMigration ? userLoginMigration.closedAt : undefined, - oauthMigrationFinalFinish: userLoginMigration ? userLoginMigration.finishedAt : undefined, - enableMigrationStart: !!school.officialSchoolNumber, - }); - - return migrationDto; - } - - private checkSchoolAuthorization(authorizableUser: User, school: LegacySchoolDo): void { - const context = AuthorizationContextBuilder.read([Permission.SCHOOL_EDIT]); - this.authService.checkPermission(authorizableUser, school, context); - } -} diff --git a/apps/server/src/modules/oauth/controller/dto/index.ts b/apps/server/src/modules/oauth/controller/dto/index.ts index 9fc38145be5..7f679725bd3 100644 --- a/apps/server/src/modules/oauth/controller/dto/index.ts +++ b/apps/server/src/modules/oauth/controller/dto/index.ts @@ -1,4 +1 @@ export * from './authorization.params'; -export * from './system-id.params'; -export * from './sso-login.query'; -export * from './user-migration.response'; diff --git a/apps/server/src/modules/oauth/controller/dto/sso-login.query.ts b/apps/server/src/modules/oauth/controller/dto/sso-login.query.ts deleted file mode 100644 index 092380cbcf2..00000000000 --- a/apps/server/src/modules/oauth/controller/dto/sso-login.query.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; - -export class SSOLoginQuery { - @IsOptional() - @IsString() - @ApiProperty() - postLoginRedirect?: string; - - @IsOptional() - @IsBoolean() - @ApiProperty() - migration?: boolean; -} diff --git a/apps/server/src/modules/oauth/controller/dto/system-id.params.ts b/apps/server/src/modules/oauth/controller/dto/system-id.params.ts deleted file mode 100644 index 04ba0017284..00000000000 --- a/apps/server/src/modules/oauth/controller/dto/system-id.params.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsMongoId } from 'class-validator'; - -export class SystemIdParams { - @IsMongoId() - @ApiProperty({ - description: 'The id of the system.', - required: true, - nullable: false, - }) - systemId!: string; -} diff --git a/apps/server/src/modules/oauth/controller/dto/user-migration.response.ts b/apps/server/src/modules/oauth/controller/dto/user-migration.response.ts deleted file mode 100644 index 88c0fdef3b4..00000000000 --- a/apps/server/src/modules/oauth/controller/dto/user-migration.response.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class UserMigrationResponse { - constructor(props: UserMigrationResponse) { - this.redirect = props.redirect; - } - - redirect: string; -} diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts index c42eeb22e24..e98100d3e43 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.spec.ts @@ -1,13 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; +import { ICurrentUser } from '@modules/authentication'; +import { HydraOauthUc } from '@modules/oauth/uc/hydra-oauth.uc'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacyLogger } from '@src/core/logger'; -import { ICurrentUser } from '@modules/authentication'; -import { HydraOauthUc } from '@modules/oauth/uc/hydra-oauth.uc'; import { Request } from 'express'; -import { OauthSSOController } from './oauth-sso.controller'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; +import { OauthSSOController } from './oauth-sso.controller'; describe('OAuthController', () => { let module: TestingModule; diff --git a/apps/server/src/modules/oauth/index.ts b/apps/server/src/modules/oauth/index.ts index 40724ec25c2..67f5f98b440 100644 --- a/apps/server/src/modules/oauth/index.ts +++ b/apps/server/src/modules/oauth/index.ts @@ -1,2 +1,3 @@ export * from './oauth.module'; export * from './interface'; +export * from './service'; diff --git a/apps/server/src/modules/oauth/mapper/user-migration.mapper.ts b/apps/server/src/modules/oauth/mapper/user-migration.mapper.ts deleted file mode 100644 index 42134d0b4d2..00000000000 --- a/apps/server/src/modules/oauth/mapper/user-migration.mapper.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { UserMigrationResponse } from '../controller/dto'; - -export class UserMigrationMapper { - static mapDtoToResponse(dto: MigrationDto): UserMigrationResponse { - const response: UserMigrationResponse = new UserMigrationResponse({ - redirect: dto.redirect, - }); - - return response; - } -} diff --git a/apps/server/src/modules/oauth/oauth-api.module.ts b/apps/server/src/modules/oauth/oauth-api.module.ts index 2efacf66adf..880f11dc731 100644 --- a/apps/server/src/modules/oauth/oauth-api.module.ts +++ b/apps/server/src/modules/oauth/oauth-api.module.ts @@ -1,10 +1,3 @@ -import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { AuthorizationModule } from '@modules/authorization'; -import { LegacySchoolModule } from '@modules/legacy-school'; -import { ProvisioningModule } from '@modules/provisioning'; -import { SystemModule } from '@modules/system'; -import { UserModule } from '@modules/user'; -import { UserLoginMigrationModule } from '@modules/user-login-migration'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { OauthSSOController } from './controller/oauth-sso.controller'; @@ -12,17 +5,7 @@ import { OauthModule } from './oauth.module'; import { HydraOauthUc } from './uc'; @Module({ - imports: [ - OauthModule, - AuthenticationModule, - AuthorizationModule, - ProvisioningModule, - LegacySchoolModule, - UserLoginMigrationModule, - SystemModule, - UserModule, - LoggerModule, - ], + imports: [OauthModule, LoggerModule], controllers: [OauthSSOController], providers: [HydraOauthUc], }) diff --git a/apps/server/src/modules/oauth/oauth.module.ts b/apps/server/src/modules/oauth/oauth.module.ts index ae0f0eda48d..3898a2e8547 100644 --- a/apps/server/src/modules/oauth/oauth.module.ts +++ b/apps/server/src/modules/oauth/oauth.module.ts @@ -1,15 +1,15 @@ -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; import { CacheWrapperModule } from '@infra/cache'; import { EncryptionModule } from '@infra/encryption'; -import { LtiToolRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; -import { ProvisioningModule } from '@modules/provisioning'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { ProvisioningModule } from '@modules/provisioning'; 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 { LtiToolRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { HydraSsoService } from './service/hydra.service'; import { OauthAdapterService } from './service/oauth-adapter.service'; import { OAuthService } from './service/oauth.service'; @@ -23,8 +23,8 @@ import { OAuthService } from './service/oauth.service'; UserModule, ProvisioningModule, SystemModule, - UserLoginMigrationModule, CacheWrapperModule, + UserLoginMigrationModule, LegacySchoolModule, ], providers: [OAuthService, OauthAdapterService, HydraSsoService, LtiToolRepo], diff --git a/apps/server/src/modules/oauth/service/index.ts b/apps/server/src/modules/oauth/service/index.ts new file mode 100644 index 00000000000..d9123c8e5bd --- /dev/null +++ b/apps/server/src/modules/oauth/service/index.ts @@ -0,0 +1,3 @@ +export * from './hydra.service'; +export * from './oauth.service'; +export * from './oauth-adapter.service'; 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 1ea2fe5ce07..adfa9603bc6 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -1,20 +1,20 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; +import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; -import { ProvisioningDto, ProvisioningService } from '@modules/provisioning'; -import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; +import { ProvisioningService } from '@modules/provisioning'; import { OauthConfigDto } from '@modules/system/service'; import { SystemDto } from '@modules/system/service/dto/system.dto'; import { SystemService } from '@modules/system/service/system.service'; import { UserService } from '@modules/user'; -import { MigrationCheckService, UserMigrationService } from '@modules/user-login-migration'; +import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity } from '@shared/domain'; -import { UserDO } from '@shared/domain/domainobject/user.do'; +import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { OauthDataDto } from '@src/modules/provisioning/dto'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; @@ -45,7 +45,6 @@ describe('OAuthService', () => { let provisioningService: DeepMocked; let userService: DeepMocked; let systemService: DeepMocked; - let userMigrationService: DeepMocked; let oauthAdapterService: DeepMocked; let migrationCheckService: DeepMocked; let schoolService: DeepMocked; @@ -85,10 +84,6 @@ describe('OAuthService', () => { provide: SystemService, useValue: createMock(), }, - { - provide: UserMigrationService, - useValue: createMock(), - }, { provide: OauthAdapterService, useValue: createMock(), @@ -105,7 +100,6 @@ describe('OAuthService', () => { provisioningService = module.get(ProvisioningService); userService = module.get(UserService); systemService = module.get(SystemService); - userMigrationService = module.get(UserMigrationService); oauthAdapterService = module.get(OauthAdapterService); migrationCheckService = module.get(MigrationCheckService); schoolService = module.get(LegacySchoolService); @@ -197,49 +191,6 @@ describe('OAuthService', () => { }); }); - describe('getPostLoginRedirectUrl is called', () => { - describe('when the oauth provider is iserv', () => { - it('should return an iserv login url string', async () => { - const system: SystemDto = new SystemDto({ - type: 'oauth', - oauthConfig: { - provider: 'iserv', - logoutEndpoint: 'http://iserv.de/logout', - } as OauthConfigDto, - }); - systemService.findById.mockResolvedValue(system); - - const result: string = await service.getPostLoginRedirectUrl('idToken', 'systemId'); - - expect(result).toEqual( - `http://iserv.de/logout?id_token_hint=idToken&post_logout_redirect_uri=https%3A%2F%2Fmock.de%2Fdashboard` - ); - }); - }); - - describe('when it is called with a postLoginRedirect and the provider is not iserv', () => { - it('should return the postLoginRedirect', async () => { - const system: SystemDto = new SystemDto({ type: 'oauth' }); - systemService.findById.mockResolvedValue(system); - - const result: string = await service.getPostLoginRedirectUrl('idToken', 'systemId', 'postLoginRedirect'); - - expect(result).toEqual('postLoginRedirect'); - }); - }); - - describe('when it is called with any other oauth provider', () => { - it('should return a login url string', async () => { - const system: SystemDto = new SystemDto({ type: 'oauth' }); - systemService.findById.mockResolvedValue(system); - - const result: string = await service.getPostLoginRedirectUrl('idToken', 'systemId'); - - expect(result).toEqual(`${hostUri}/dashboard`); - }); - }); - }); - describe('authenticateUser is called', () => { const setup = () => { const authCode = '43534543jnj543342jn2'; @@ -332,231 +283,489 @@ describe('OAuthService', () => { }); }); - describe('provisionUser is called', () => { - describe('when only provisioning a user', () => { - it('should return the user and a redirect', async () => { + describe('provisionUser', () => { + describe('when provisioning a user and a school without official school number', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; const externalUserId = 'externalUserId'; - const user: UserDO = userDoFactory.buildWithId({ externalId: externalUserId }); - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - const provisioningDto: ProvisioningDto = new ProvisioningDto({ - externalUserId, + + const user: UserDO = userDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalUserId, }); - provisioningService.getData.mockResolvedValue(oauthData); - provisioningService.provisionData.mockResolvedValue(provisioningDto); - userService.findByExternalId.mockResolvedValue(user); + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { + externalId: externalUserId, + }, + externalSchool: { + externalId: 'externalSchoolId', + name: 'External School', + }, + }); - const result: { user?: UserDO; redirect: string } = await service.provisionUser( - 'systemId', - 'idToken', - 'accessToken' - ); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + userService.findByExternalId.mockResolvedValueOnce(user); - expect(result).toEqual<{ user?: UserDO; redirect: string }>({ + return { + systemId, + idToken, + accessToken, + provisioningData, user, - redirect: `${hostUri}/dashboard`, - }); + }; + }; + + it('should provision the data', async () => { + const { systemId, idToken, accessToken, provisioningData } = setup(); + + await service.provisionUser(systemId, idToken, accessToken); + + expect(provisioningService.provisionData).toHaveBeenCalledWith(provisioningData); + }); + + it('should return the user', async () => { + const { systemId, idToken, accessToken, user } = setup(); + + const result = await service.provisionUser(systemId, idToken, accessToken); + + expect(result).toEqual(user); }); }); - describe('when provisioning a user that should migrate, but the user does not exist', () => { - it('should return a redirect to the migration page and skip provisioning', async () => { - const migrationRedirectUrl = 'migrationRedirectUrl'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), - externalSchool: new ExternalSchoolDto({ - externalId: 'schoolExternalId', - name: 'externalSchool', - officialSchoolNumber: 'officialSchoolNumber', - }), + describe('when provisioning a user and a school without official school number, but the user cannot be found after the provisioning', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; + const externalUserId = 'externalUserId'; + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { + externalId: externalUserId, + }, + externalSchool: { + externalId: 'externalSchoolId', + name: 'External School', + }, }); - provisioningService.getData.mockResolvedValue(oauthData); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(null); - migrationCheckService.shouldUserMigrate.mockResolvedValue(true); - userMigrationService.getMigrationConsentPageRedirect.mockResolvedValue(migrationRedirectUrl); - userService.findByExternalId.mockResolvedValue(null); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + userService.findByExternalId.mockResolvedValueOnce(null); - const result: { user?: UserDO; redirect: string } = await service.provisionUser( - 'systemId', - 'idToken', - 'accessToken' - ); + return { + systemId, + idToken, + accessToken, + provisioningData, + }; + }; - expect(result).toEqual<{ user?: UserDO; redirect: string }>({ - user: undefined, - redirect: migrationRedirectUrl, - }); - expect(provisioningService.provisionData).not.toHaveBeenCalled(); + it('should throw an error', async () => { + const { systemId, idToken, accessToken } = setup(); + + await expect(service.provisionUser(systemId, idToken, accessToken)).rejects.toThrow( + UserNotFoundAfterProvisioningLoggableException + ); }); }); - describe('when provisioning an existing user that should migrate', () => { - it('should return a redirect to the migration page and provision the user', async () => { - const migrationRedirectUrl = 'migrationRedirectUrl'; + describe('when provisioning a user and a new school with official school number', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; const externalUserId = 'externalUserId'; - const user: UserDO = userDoFactory.buildWithId({ externalId: externalUserId }); - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ + + const user: UserDO = userDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalUserId, + }); + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { externalId: externalUserId, - }), - externalSchool: new ExternalSchoolDto({ - externalId: 'schoolExternalId', - name: 'externalSchool', + }, + externalSchool: { + externalId: 'externalSchoolId', + name: 'External School', officialSchoolNumber: 'officialSchoolNumber', - }), + }, + }); + + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(null); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(false); + userService.findByExternalId.mockResolvedValueOnce(user); + + return { + systemId, + idToken, + accessToken, + provisioningData, + user, + }; + }; + + it('should provision the data', async () => { + const { systemId, idToken, accessToken, provisioningData } = setup(); + + await service.provisionUser(systemId, idToken, accessToken); + + expect(provisioningService.provisionData).toHaveBeenCalledWith(provisioningData); + }); + + it('should return the user', async () => { + const { systemId, idToken, accessToken, user } = setup(); + + const result = await service.provisionUser(systemId, idToken, accessToken); + + expect(result).toEqual(user); + }); + }); + + describe('when provisioning a user and an existing school with official school number, that has provisioning enabled', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const officialSchoolNumber = 'officialSchoolNumber'; + + const user: UserDO = userDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalUserId, }); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalSchoolId, + officialSchoolNumber, features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], }); - provisioningService.getData.mockResolvedValue(oauthData); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); - migrationCheckService.shouldUserMigrate.mockResolvedValue(true); - userMigrationService.getMigrationConsentPageRedirect.mockResolvedValue(migrationRedirectUrl); - userService.findByExternalId.mockResolvedValue(user); + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { + externalId: externalUserId, + }, + externalSchool: { + externalId: externalSchoolId, + name: school.name, + officialSchoolNumber, + }, + }); - const result: { user?: UserDO; redirect: string } = await service.provisionUser( - 'systemId', - 'idToken', - 'accessToken' - ); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(school); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(false); + userService.findByExternalId.mockResolvedValueOnce(user); - expect(result).toEqual<{ user?: UserDO; redirect: string }>({ + return { + systemId, + idToken, + accessToken, + provisioningData, user, - redirect: migrationRedirectUrl, - }); - expect(provisioningService.provisionData).toHaveBeenCalled(); + }; + }; + + it('should provision the data', async () => { + const { systemId, idToken, accessToken, provisioningData } = setup(); + + await service.provisionUser(systemId, idToken, accessToken); + + expect(provisioningService.provisionData).toHaveBeenCalledWith(provisioningData); + }); + + it('should return the user', async () => { + const { systemId, idToken, accessToken, user } = setup(); + + const result = await service.provisionUser(systemId, idToken, accessToken); + + expect(result).toEqual(user); }); }); - describe('when provisioning an existing user, that is in a school with provisioning disabled', () => { + describe('when provisioning an existing user and an existing school with official school number, that has provisioning disabled', () => { const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; const externalUserId = 'externalUserId'; - const user: UserDO = userDoFactory.buildWithId({ externalId: externalUserId }); - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ + const externalSchoolId = 'externalSchoolId'; + const officialSchoolNumber = 'officialSchoolNumber'; + + const user: UserDO = userDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalUserId, + }); + + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalSchoolId, + officialSchoolNumber, + features: [], + }); + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { externalId: externalUserId, - }), - externalSchool: new ExternalSchoolDto({ - externalId: 'schoolExternalId', - name: 'externalSchool', - officialSchoolNumber: 'officialSchoolNumber', - }), + }, + externalSchool: { + externalId: externalSchoolId, + name: school.name, + officialSchoolNumber, + }, }); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: [] }); - provisioningService.getData.mockResolvedValue(oauthData); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); - migrationCheckService.shouldUserMigrate.mockResolvedValue(false); - userService.findByExternalId.mockResolvedValue(user); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(school); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(false); + userService.findByExternalId.mockResolvedValueOnce(user); return { + systemId, + idToken, + accessToken, + provisioningData, user, }; }; - it('should not provision the user, but find it', async () => { - const { user } = setup(); + it('should not provision the data', async () => { + const { systemId, idToken, accessToken } = setup(); - const result: { user?: UserDO; redirect: string } = await service.provisionUser( - 'systemId', - 'idToken', - 'accessToken' - ); + await service.provisionUser(systemId, idToken, accessToken); - expect(result).toEqual<{ user?: UserDO; redirect: string }>({ - user, - redirect: 'https://mock.de/dashboard', - }); expect(provisioningService.provisionData).not.toHaveBeenCalled(); }); + + it('should return the user', async () => { + const { systemId, idToken, accessToken, user } = setup(); + + const result = await service.provisionUser(systemId, idToken, accessToken); + + expect(result).toEqual(user); + }); }); - describe('when provisioning a new user, that is in a school with provisioning disabled', () => { + describe('when provisioning a new user and an existing school with official school number, that has provisioning disabled', () => { const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; const externalUserId = 'externalUserId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ + const externalSchoolId = 'externalSchoolId'; + const officialSchoolNumber = 'officialSchoolNumber'; + + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalSchoolId, + officialSchoolNumber, + features: [], + }); + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { externalId: externalUserId, - }), - externalSchool: new ExternalSchoolDto({ - externalId: 'schoolExternalId', - name: 'externalSchool', - officialSchoolNumber: 'officialSchoolNumber', - }), + }, + externalSchool: { + externalId: externalSchoolId, + name: school.name, + officialSchoolNumber, + }, }); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: [] }); - provisioningService.getData.mockResolvedValue(oauthData); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); - migrationCheckService.shouldUserMigrate.mockResolvedValue(false); - userService.findByExternalId.mockResolvedValue(null); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(school); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(false); + userService.findByExternalId.mockResolvedValueOnce(null); + + return { + systemId, + idToken, + accessToken, + provisioningData, + }; }; - it('should throw UserNotFoundAfterProvisioningLoggableException', async () => { - setup(); + it('should not provision the data', async () => { + const { systemId, idToken, accessToken } = setup(); - const func = () => service.provisionUser('systemId', 'idToken', 'accessToken'); + await expect(service.provisionUser(systemId, idToken, accessToken)).rejects.toThrow(); - await expect(func).rejects.toThrow(UserNotFoundAfterProvisioningLoggableException); expect(provisioningService.provisionData).not.toHaveBeenCalled(); }); + + it('should throw an error', async () => { + const { systemId, idToken, accessToken } = setup(); + + await expect(service.provisionUser(systemId, idToken, accessToken)).rejects.toThrow( + UserNotFoundAfterProvisioningLoggableException + ); + }); }); - describe('when the user cannot be found after provisioning', () => { + describe('when provisioning a new user and an existing school with official school number, that is currently migrating', () => { const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; const externalUserId = 'externalUserId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ + const externalSchoolId = 'externalSchoolId'; + const officialSchoolNumber = 'officialSchoolNumber'; + + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalSchoolId, + officialSchoolNumber, + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { externalId: externalUserId, - }), + }, + externalSchool: { + externalId: externalSchoolId, + name: school.name, + officialSchoolNumber, + }, + }); + + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(school); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(true); + userService.findByExternalId.mockResolvedValueOnce(null); + + return { + systemId, + idToken, + accessToken, + provisioningData, + }; + }; + + it('should not provision the data', async () => { + const { systemId, idToken, accessToken } = setup(); + + await service.provisionUser(systemId, idToken, accessToken); + + expect(provisioningService.provisionData).not.toHaveBeenCalled(); + }); + + it('should return null', async () => { + const { systemId, idToken, accessToken } = setup(); + + const result = await service.provisionUser(systemId, idToken, accessToken); + + expect(result).toBeNull(); + }); + }); + + describe('when provisioning an existing user and an existing school with official school number, that is currently migrating', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const idToken = 'idToken'; + const accessToken = 'accessToken'; + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const officialSchoolNumber = 'officialSchoolNumber'; + + const user: UserDO = userDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalUserId, }); - const provisioningDto: ProvisioningDto = new ProvisioningDto({ - externalUserId, + + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: externalSchoolId, + officialSchoolNumber, + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + const provisioningData: OauthDataDto = new OauthDataDto({ + system: { + systemId, + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl: 'https://mock.person-info.de/', + }, + externalUser: { + externalId: externalUserId, + }, + externalSchool: { + externalId: externalSchoolId, + name: school.name, + officialSchoolNumber, + }, }); - provisioningService.getData.mockResolvedValue(oauthData); - provisioningService.provisionData.mockResolvedValue(provisioningDto); - userService.findByExternalId.mockResolvedValue(null); + provisioningService.getData.mockResolvedValueOnce(provisioningData); + schoolService.getSchoolBySchoolNumber.mockResolvedValueOnce(school); + migrationCheckService.shouldUserMigrate.mockResolvedValueOnce(true); + userService.findByExternalId.mockResolvedValueOnce(user).mockResolvedValueOnce(user); + + return { + systemId, + idToken, + accessToken, + provisioningData, + user, + }; }; - it('should throw an error', async () => { - setup(); + it('should provision the data', async () => { + const { systemId, idToken, accessToken, provisioningData } = setup(); + + await service.provisionUser(systemId, idToken, accessToken); + + expect(provisioningService.provisionData).toHaveBeenCalledWith(provisioningData); + }); + + it('should return the user', async () => { + const { systemId, idToken, accessToken, user } = setup(); - const func = () => service.provisionUser('systemId', 'idToken', 'accessToken'); + const result = await service.provisionUser(systemId, idToken, accessToken); - await expect(func).rejects.toThrow(UserNotFoundAfterProvisioningLoggableException); + expect(result).toEqual(user); }); }); }); diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 1f7935d919e..5e787d79082 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,19 +1,17 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; -import { Inject } from '@nestjs/common'; -import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { EntityId, LegacySchoolDo, OauthConfig, SchoolFeatures, UserDO } from '@shared/domain'; import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; -import { LegacyLogger } from '@src/core/logger'; -import { ProvisioningService } from '@modules/provisioning'; -import { OauthDataDto } from '@modules/provisioning/dto'; import { LegacySchoolService } from '@modules/legacy-school'; +import { ProvisioningService, OauthDataDto } from '@modules/provisioning'; import { SystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; -import { MigrationCheckService, UserMigrationService } from '@modules/user-login-migration'; +import { MigrationCheckService } from '@modules/user-login-migration'; +import { Inject } from '@nestjs/common'; +import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; +import { EntityId, LegacySchoolDo, OauthConfig, SchoolFeatures, UserDO } from '@shared/domain'; +import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { TokenRequestMapper } from '../mapper/token-request.mapper'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -27,7 +25,6 @@ export class OAuthService { private readonly logger: LegacyLogger, private readonly provisioningService: ProvisioningService, private readonly systemService: SystemService, - private readonly userMigrationService: UserMigrationService, private readonly migrationCheckService: MigrationCheckService, private readonly schoolService: LegacySchoolService ) { @@ -60,22 +57,16 @@ export class OAuthService { return oauthTokens; } - async provisionUser( - systemId: string, - idToken: string, - accessToken: string, - postLoginRedirect?: string - ): Promise<{ user?: UserDO; redirect: string }> { + async provisionUser(systemId: string, idToken: string, accessToken: string): Promise { const data: OauthDataDto = await this.provisioningService.getData(systemId, idToken, accessToken); const externalUserId: string = data.externalUser.externalId; const officialSchoolNumber: string | undefined = data.externalSchool?.officialSchoolNumber; - let provisioning = true; - let migrationConsentRedirect: string | undefined; + let isProvisioningEnabled = true; if (officialSchoolNumber) { - provisioning = await this.isOauthProvisioningEnabledForSchool(officialSchoolNumber); + isProvisioningEnabled = await this.isOauthProvisioningEnabledForSchool(officialSchoolNumber); const shouldUserMigrate: boolean = await this.migrationCheckService.shouldUserMigrate( externalUserId, @@ -84,33 +75,21 @@ export class OAuthService { ); if (shouldUserMigrate) { - // TODO: https://ticketsystem.dbildungscloud.de/browse/N21-632 Move Redirect Logic URLs to Client - migrationConsentRedirect = await this.userMigrationService.getMigrationConsentPageRedirect( - officialSchoolNumber, - systemId - ); - const existingUser: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); + if (!existingUser) { - return { user: undefined, redirect: migrationConsentRedirect }; + return null; } } } - if (provisioning) { + if (isProvisioningEnabled) { await this.provisioningService.provisionData(data); } const user: UserDO = await this.findUserAfterProvisioningOrThrow(externalUserId, systemId, officialSchoolNumber); - // TODO: https://ticketsystem.dbildungscloud.de/browse/N21-632 Move Redirect Logic URLs to Client - const redirect: string = await this.getPostLoginRedirectUrl( - idToken, - systemId, - postLoginRedirect || migrationConsentRedirect - ); - - return { user, redirect }; + return user; } private async findUserAfterProvisioningOrThrow( @@ -166,26 +145,6 @@ export class OAuthService { return decodedJWT; } - async getPostLoginRedirectUrl(idToken: string, systemId: string, postLoginRedirect?: string): Promise { - const clientUrl: string = Configuration.get('HOST') as string; - const dashboardUrl: URL = new URL('/dashboard', clientUrl); - const system: SystemDto = await this.systemService.findById(systemId); - - let redirect: string; - if (system.oauthConfig?.provider === 'iserv' && system.oauthConfig?.logoutEndpoint) { - const iservLogoutUrl: URL = new URL(system.oauthConfig.logoutEndpoint); - iservLogoutUrl.searchParams.append('id_token_hint', idToken); - iservLogoutUrl.searchParams.append('post_logout_redirect_uri', postLoginRedirect || dashboardUrl.toString()); - redirect = iservLogoutUrl.toString(); - } else if (postLoginRedirect) { - redirect = postLoginRedirect; - } else { - redirect = dashboardUrl.toString(); - } - - return redirect; - } - private buildTokenRequestPayload( code: string, oauthConfig: OauthConfig, 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 339bc6c09d9..b889995b9e5 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 @@ -7,8 +7,7 @@ import { OauthConfig } from '@shared/domain'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; -import { HydraSsoService } from '@modules/oauth/service/hydra.service'; -import { OAuthService } from '@modules/oauth/service/oauth.service'; +import { OAuthService, HydraSsoService } from '@modules/oauth'; import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; diff --git a/apps/server/src/modules/provisioning/index.ts b/apps/server/src/modules/provisioning/index.ts index 0e0bc64d04a..caf7d1f3483 100644 --- a/apps/server/src/modules/provisioning/index.ts +++ b/apps/server/src/modules/provisioning/index.ts @@ -1,4 +1,4 @@ export * from './provisioning.module'; -export * from './dto/provisioning.dto'; +export * from './dto'; export * from './service/provisioning.service'; export * from './strategy'; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index a812ff773b2..c048156d08b 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -1,4 +1,8 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { MailModule } from '@infra/mail'; +import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { REDIS_CLIENT, RedisModule } from '@infra/redis'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AccountApiModule } from '@modules/account/account-api.module'; @@ -8,7 +12,6 @@ import { CollaborativeStorageModule } from '@modules/collaborative-storage'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { GroupApiModule } from '@modules/group/group-api.module'; import { LearnroomApiModule } from '@modules/learnroom/learnroom-api.module'; -import { LegacySchoolApiModule } from '@modules/legacy-school/legacy-school-api.module'; import { LessonApiModule } from '@modules/lesson/lesson-api.module'; import { MetaTagExtractorApiModule, MetaTagExtractorModule } from '@modules/meta-tag-extractor'; import { NewsModule } from '@modules/news'; @@ -28,10 +31,6 @@ import { VideoConferenceApiModule } from '@modules/video-conference/video-confer import { DynamicModule, Inject, MiddlewareConsumer, Module, NestModule, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain'; -import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; -import { MailModule } from '@infra/mail'; -import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; -import { RedisModule, REDIS_CLIENT } from '@infra/redis'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; @@ -68,7 +67,6 @@ const serverModules = [ adminUser: Configuration.get('ROCKET_CHAT_ADMIN_USER') as string, adminPassword: Configuration.get('ROCKET_CHAT_ADMIN_PASSWORD') as string, }), - LegacySchoolApiModule, VideoConferenceApiModule, OauthProviderApiModule, SharingApiModule, diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts index d3ca547a854..1f76ec01a14 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts @@ -109,104 +109,93 @@ describe('CommonToolValidationService', () => { }); }); - describe('checkForDuplicateParameters', () => { - describe('when given parameters has a case sensitive duplicate', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - parameters: [ - { name: 'nameDuplicate', value: 'value' }, - { name: 'nameDuplicate', value: 'value' }, - ], - }); + describe('checkCustomParameterEntries', () => { + const createTools = ( + externalToolMock?: Partial, + schoolExternalToolMock?: Partial, + contextExternalToolMock?: Partial + ) => { + const externalTool: ExternalTool = new ExternalTool({ + ...externalToolFactory.buildWithId(), + ...externalToolMock, + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + ...schoolExternalToolFactory.buildWithId(), + ...schoolExternalToolMock, + }); + const schoolExternalToolId = schoolExternalTool.id as string; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ...contextExternalToolFactory.buildWithId(), + ...contextExternalToolMock, + }); - return { - schoolExternalTool, - }; + return { + externalTool, + schoolExternalTool, + schoolExternalToolId, + contextExternalTool, }; + }; - it('should throw error', () => { - const { schoolExternalTool } = setup(); - - const func = () => service.checkForDuplicateParameters(schoolExternalTool); - - expect(func).toThrow('tool_param_duplicate'); - }); - }); - - describe('when given parameters has case insensitive duplicate', () => { + describe('when a parameter is a duplicate', () => { const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + const externalTool: ExternalTool = externalToolFactory.buildWithId({ parameters: [ - { name: 'nameDuplicate', value: 'value' }, - { name: 'nameduplicate', value: 'value' }, + customParameterFactory.build({ + name: 'duplicate', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: true, + }), + ], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + parameters: [ + { name: 'duplicate', value: undefined }, + { name: 'duplicate', value: undefined }, ], }); return { + externalTool, schoolExternalTool, }; }; - it('should throw error when given parameters has case insensitive duplicate', () => { - const { schoolExternalTool } = setup(); + it('should throw error', () => { + const { externalTool, schoolExternalTool } = setup(); - const func = () => service.checkForDuplicateParameters(schoolExternalTool); + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); expect(func).toThrowError('tool_param_duplicate'); }); }); - describe('when given parameters has no duplicates', () => { + describe('when a parameter is unknown', () => { const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - parameters: [ - { name: 'nameNoDuplicate1', value: 'value' }, - { name: 'nameNoDuplicate2', value: 'value' }, - ], + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + parameters: [], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + parameters: [{ name: 'unknownParameter', value: undefined }], }); return { + externalTool, schoolExternalTool, }; }; - it('when given parameters has no duplicates should return without error', () => { - const { schoolExternalTool } = setup(); + it('should throw error', () => { + const { externalTool, schoolExternalTool } = setup(); - const func = () => service.checkForDuplicateParameters(schoolExternalTool); + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - expect(func).not.toThrowError('tool_param_duplicate'); + expect(func).toThrowError('tool_param_unknown'); }); }); - }); - - describe('checkCustomParameterEntries', () => { - const createTools = ( - externalToolMock?: Partial, - schoolExternalToolMock?: Partial, - contextExternalToolMock?: Partial - ) => { - const externalTool: ExternalTool = new ExternalTool({ - ...externalToolFactory.buildWithId(), - ...externalToolMock, - }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - ...schoolExternalToolFactory.buildWithId(), - ...schoolExternalToolMock, - }); - const schoolExternalToolId = schoolExternalTool.id as string; - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ - ...contextExternalToolFactory.buildWithId(), - ...contextExternalToolMock, - }); - - return { - externalTool, - schoolExternalTool, - schoolExternalToolId, - contextExternalTool, - }; - }; describe('when checking parameter is required', () => { describe('and given parameter is not optional and parameter value is empty', () => { @@ -280,16 +269,23 @@ describe('CommonToolValidationService', () => { describe('when parameter is not school or context', () => { const setup = () => { - const notSchoolParam: CustomParameter = customParameterFactory.build({ - name: 'notSchoolParam', - scope: CustomParameterScope.GLOBAL, - type: CustomParameterType.BOOLEAN, - }); - const { externalTool, schoolExternalTool } = createTools( - { parameters: [notSchoolParam] }, { - parameters: [{ name: 'name', value: 'true' }], + parameters: [ + customParameterFactory.build({ + name: 'notSchoolParam', + scope: CustomParameterScope.GLOBAL, + type: CustomParameterType.BOOLEAN, + }), + customParameterFactory.build({ + name: 'schoolParam', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.BOOLEAN, + }), + ], + }, + { + parameters: [{ name: 'schoolParam', value: 'true' }], } ); @@ -320,7 +316,7 @@ describe('CommonToolValidationService', () => { const { externalTool, schoolExternalTool } = createTools( { parameters: [missingParam] }, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [], } ); @@ -339,18 +335,26 @@ describe('CommonToolValidationService', () => { }); }); - describe('when parameter is optional but is missing on params', () => { + describe('when parameter is optional and was not defined', () => { const setup = () => { - const param: CustomParameter = customParameterFactory.build({ - name: 'notChecked', - scope: CustomParameterScope.SCHOOL, - isOptional: true, - }); - const { externalTool, schoolExternalTool } = createTools( - { parameters: [param] }, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [ + customParameterFactory.build({ + name: 'optionalParameter', + scope: CustomParameterScope.SCHOOL, + isOptional: true, + }), + customParameterFactory.build({ + name: 'requiredParameter', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + ], + }, + { + parameters: [{ name: 'requiredParameter', value: 'value' }], } ); @@ -360,7 +364,7 @@ describe('CommonToolValidationService', () => { }; }; - it('should return without error ', () => { + it('should return without error', () => { const { externalTool, schoolExternalTool } = setup(); const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); @@ -385,7 +389,7 @@ describe('CommonToolValidationService', () => { }, undefined, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [], } ); diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts index 3ee9ee7d465..e6c3a31b288 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts @@ -29,12 +29,25 @@ export class CommonToolValidationService { return isValid; } - public checkForDuplicateParameters(validatableTool: ValidatableTool): void { - const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => - name.toLowerCase() + public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { + this.checkForDuplicateParameters(validatableTool); + + const parametersForScope: CustomParameter[] = (loadedExternalTool.parameters ?? []).filter( + (param: CustomParameter) => + (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || + (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) ); + this.checkForUnknownParameters(validatableTool, parametersForScope); + + this.checkValidityOfParameters(validatableTool, parametersForScope); + } + + private checkForDuplicateParameters(validatableTool: ValidatableTool): void { + const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => name); + const uniqueNames: Set = new Set(caseInsensitiveNames); + if (uniqueNames.size !== validatableTool.parameters.length) { throw new ValidationError( `tool_param_duplicate: The tool ${validatableTool.id ?? ''} contains multiple of the same custom parameters.` @@ -42,28 +55,33 @@ export class CommonToolValidationService { } } - public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { - if (loadedExternalTool.parameters) { - for (const param of loadedExternalTool.parameters) { - this.checkScopeAndValidateParameter(validatableTool, param); + private checkForUnknownParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { + for (const entry of validatableTool.parameters) { + const foundParameter: CustomParameter | undefined = parametersForScope.find( + ({ name }: CustomParameter): boolean => name === entry.name + ); + + if (!foundParameter) { + throw new ValidationError( + `tool_param_unknown: The parameter with name ${entry.name} is not part of this tool.` + ); } } } - private checkScopeAndValidateParameter(validatableTool: ValidatableTool, param: CustomParameter): void { - const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( - ({ name }: CustomParameterEntry): boolean => name.toLowerCase() === param.name.toLowerCase() - ); + private checkValidityOfParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { + for (const param of parametersForScope) { + const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( + ({ name }: CustomParameterEntry): boolean => name === param.name + ); - if (param.scope === CustomParameterScope.SCHOOL && validatableTool instanceof SchoolExternalTool) { - this.validateParameter(param, foundEntry); - } else if (param.scope === CustomParameterScope.CONTEXT && validatableTool instanceof ContextExternalTool) { this.validateParameter(param, foundEntry); } } private validateParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { this.checkOptionalParameter(param, foundEntry); + if (foundEntry) { this.checkParameterType(foundEntry, param); this.checkParameterRegex(foundEntry, param); diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index 71f643c81ac..1dccc42ab1f 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -5,8 +5,12 @@ import { ContextExternalTool } from '../../context-external-tool/domain'; import { ToolConfigurationStatus } from '../enum'; import { ToolVersion } from '../interface'; +// TODO N21-1337 remove class when tool versioning is removed @Injectable() export class CommonToolService { + /** + * @deprecated use ToolVersionService + */ determineToolConfigurationStatus( externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, 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 1afd639f1e7..d73e8a25171 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 @@ -3,26 +3,26 @@ import { LoggerModule } from '@src/core/logger'; import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; -import { - ContextExternalToolAuthorizableService, - ContextExternalToolService, - ContextExternalToolValidationService, - ToolReferenceService, -} from './service'; +import { ContextExternalToolAuthorizableService, ContextExternalToolService, ToolReferenceService } from './service'; +import { ContextExternalToolValidationService } from './service/context-external-tool-validation.service'; +import { ToolConfigModule } from '../tool-config.module'; +import { ToolVersionService } from './service/tool-version-service'; @Module({ - imports: [CommonToolModule, ExternalToolModule, SchoolExternalToolModule, LoggerModule], + imports: [CommonToolModule, ExternalToolModule, SchoolExternalToolModule, LoggerModule, ToolConfigModule], providers: [ ContextExternalToolService, ContextExternalToolValidationService, ContextExternalToolAuthorizableService, ToolReferenceService, + ToolVersionService, ], exports: [ ContextExternalToolService, ContextExternalToolValidationService, ContextExternalToolAuthorizableService, ToolReferenceService, + ToolVersionService, ], }) export class ContextExternalToolModule {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index eb570130c4e..af14eb379f3 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -17,9 +17,8 @@ import { userFactory, } from '@shared/testing'; import { ObjectId } from 'bson'; -import { CustomParameterScope, ToolContextType } from '../../../common/enum'; +import { CustomParameterScope, CustomParameterType, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; -import { CustomParameterEntryResponse } from '../../../school-external-tool/controller/dto'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; import { @@ -66,10 +65,27 @@ describe('ToolContextController (API)', () => { const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const paramEntry: CustomParameterEntryResponse = { name: 'name', value: 'value' }; + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], + version: 1, + }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, - schoolParameters: [paramEntry], + schoolParameters: [], toolVersion: 1, }); @@ -78,7 +94,10 @@ describe('ToolContextController (API)', () => { contextId: course.id, displayName: course.name, contextType: ToolContextType.COURSE, - parameters: [paramEntry], + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: '' }, + ], toolVersion: 1, }; @@ -87,30 +106,30 @@ describe('ToolContextController (API)', () => { const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); - const expected: ContextExternalToolResponse = { - id: expect.any(String), - schoolToolId: postParams.schoolToolId, - contextId: postParams.contextId, - displayName: postParams.displayName, - contextType: postParams.contextType, - parameters: [paramEntry], - toolVersion: postParams.toolVersion, - }; - return { loggedInClient, postParams, - expected, }; }; it('should create a contextExternalTool', async () => { - const { postParams, loggedInClient, expected } = await setup(); + const { postParams, loggedInClient } = await setup(); const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(response.body).toEqual(expect.objectContaining(expected)); + expect(response.body).toEqual({ + id: expect.any(String), + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: undefined }, + ], + toolVersion: postParams.toolVersion, + }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts index 951559afbcc..8e72f5f540b 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts @@ -25,7 +25,7 @@ export class ContextExternalToolRequestMapper { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, - value: customParameterParam.value, + value: customParameterParam.value || undefined, }; }); } diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index 2cce83a08b2..b12193eadd5 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -18,7 +18,7 @@ export class ContextExternalToolValidationService { ) {} async validate(contextExternalTool: ContextExternalTool): Promise { - await this.checkDuplicateInContext(contextExternalTool); + await this.checkDuplicateUsesInContext(contextExternalTool); const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId @@ -29,7 +29,7 @@ export class ContextExternalToolValidationService { this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, contextExternalTool); } - private async checkDuplicateInContext(contextExternalTool: ContextExternalTool) { + private async checkDuplicateUsesInContext(contextExternalTool: ContextExternalTool) { let duplicate: ContextExternalTool[] = await this.contextExternalToolService.findContextExternalTools({ schoolToolRef: contextExternalTool.schoolToolRef, context: contextExternalTool.contextRef, diff --git a/apps/server/src/modules/tool/context-external-tool/service/index.ts b/apps/server/src/modules/tool/context-external-tool/service/index.ts index 31fedbe42af..30458b4b9c3 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/index.ts @@ -1,4 +1,3 @@ export * from './context-external-tool.service'; -export * from './context-external-tool-validation.service'; export * from './context-external-tool-authorizable.service'; export * from './tool-reference.service'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts index e434ab49527..1796b2f681d 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts @@ -3,12 +3,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; import { ToolConfigurationStatus } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ToolReference } from '../domain'; import { ContextExternalToolService } from './context-external-tool.service'; import { ToolReferenceService } from './tool-reference.service'; +import { ToolVersionService } from './tool-version-service'; describe('ToolReferenceService', () => { let module: TestingModule; @@ -17,7 +17,7 @@ describe('ToolReferenceService', () => { let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; - let commonToolService: DeepMocked; + let toolVersionService: DeepMocked; let externalToolLogoService: DeepMocked; beforeAll(async () => { @@ -37,8 +37,8 @@ describe('ToolReferenceService', () => { useValue: createMock(), }, { - provide: CommonToolService, - useValue: createMock(), + provide: ToolVersionService, + useValue: createMock(), }, { provide: ExternalToolLogoService, @@ -51,7 +51,7 @@ describe('ToolReferenceService', () => { externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); - commonToolService = module.get(CommonToolService); + toolVersionService = module.get(ToolVersionService); externalToolLogoService = module.get(ExternalToolLogoService); }); @@ -79,7 +79,7 @@ describe('ToolReferenceService', () => { contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); - commonToolService.determineToolConfigurationStatus.mockReturnValue(ToolConfigurationStatus.OUTDATED); + toolVersionService.determineToolConfigurationStatus.mockResolvedValue(ToolConfigurationStatus.OUTDATED); externalToolLogoService.buildLogoUrl.mockReturnValue(logoUrl); return { @@ -96,7 +96,7 @@ describe('ToolReferenceService', () => { await service.getToolReference(contextExternalToolId); - expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( + expect(toolVersionService.determineToolConfigurationStatus).toHaveBeenCalledWith( externalTool, schoolExternalTool, contextExternalTool diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts index 02c6a08677e..1f677e3e159 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { ToolConfigurationStatus } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; @@ -9,6 +7,7 @@ import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool, ToolReference } from '../domain'; import { ToolReferenceMapper } from '../mapper'; import { ContextExternalToolService } from './context-external-tool.service'; +import { ToolVersionService } from './tool-version-service'; @Injectable() export class ToolReferenceService { @@ -16,8 +15,8 @@ export class ToolReferenceService { private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly commonToolService: CommonToolService, - private readonly externalToolLogoService: ExternalToolLogoService + private readonly externalToolLogoService: ExternalToolLogoService, + private readonly toolVersionService: ToolVersionService ) {} async getToolReference(contextExternalToolId: EntityId): Promise { @@ -29,7 +28,7 @@ export class ToolReferenceService { ); const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); - const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( + const status = await this.toolVersionService.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts new file mode 100644 index 00000000000..33c77ecb7ea --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts @@ -0,0 +1,196 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; +import { ApiValidationError } from '@shared/common'; +import { SchoolExternalToolValidationService } from '../../school-external-tool/service'; +import { ToolVersionService } from './tool-version-service'; +import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; +import { CommonToolService } from '../../common/service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ToolConfigurationStatus } from '../../common/enum'; + +describe('ToolVersionService', () => { + let module: TestingModule; + let service: ToolVersionService; + + let contextExternalToolValidationService: DeepMocked; + let schoolExternalToolValidationService: DeepMocked; + let commonToolService: DeepMocked; + let toolFeatures: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ToolVersionService, + { + provide: ContextExternalToolValidationService, + useValue: createMock(), + }, + { + provide: SchoolExternalToolValidationService, + useValue: createMock(), + }, + { + provide: CommonToolService, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + toolStatusWithoutVersions: false, + }, + }, + ], + }).compile(); + + service = module.get(ToolVersionService); + contextExternalToolValidationService = module.get(ContextExternalToolValidationService); + schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); + commonToolService = module.get(CommonToolService); + toolFeatures = module.get(ToolFeatures); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('determineToolConfigurationStatus', () => { + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is false', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = false; + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should call CommonToolService', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( + externalTool, + schoolExternalTool, + contextExternalTool + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and validation runs through', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockResolvedValue(); + contextExternalToolValidationService.validate.mockResolvedValueOnce(); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return latest tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual(ToolConfigurationStatus.LATEST); + }); + + it('should call schoolExternalToolValidationService', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + }); + + it('should call contextExternalToolValidationService', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and validation throws an error', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockResolvedValue(); + contextExternalToolValidationService.validate.mockRejectedValueOnce(ApiValidationError); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return outdated tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual(ToolConfigurationStatus.OUTDATED); + }); + + it('should call schoolExternalToolValidationService', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + }); + + it('should call contextExternalToolValidationService', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.determineToolConfigurationStatus(externalTool, schoolExternalTool, contextExternalTool); + + expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts new file mode 100644 index 00000000000..2ba0709372a --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; +import { Inject } from '@nestjs/common'; +import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; +import { SchoolExternalToolValidationService } from '../../school-external-tool/service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { ExternalTool } from '../../external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ContextExternalTool } from '../domain'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { CommonToolService } from '../../common/service'; + +@Injectable() +export class ToolVersionService { + constructor( + private readonly contextExternalToolValidationService: ContextExternalToolValidationService, + private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, + private readonly commonToolService: CommonToolService, + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures + ) {} + + async determineToolConfigurationStatus( + externalTool: ExternalTool, + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalTool + ): Promise { + // TODO N21-1337 remove if statement, when feature flag is removed + if (this.toolFeatures.toolStatusWithoutVersions) { + try { + await this.schoolExternalToolValidationService.validate(schoolExternalTool); + await this.contextExternalToolValidationService.validate(contextExternalTool); + return ToolConfigurationStatus.LATEST; + } catch (err) { + return ToolConfigurationStatus.OUTDATED; + } + } + const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + return status; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index 0fcca404049..9239e4db2a9 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -16,7 +16,8 @@ import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool } from '../domain'; -import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; +import { ContextExternalToolService } from '../service'; +import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; import { ContextExternalToolUc } from './context-external-tool.uc'; describe('ContextExternalToolUc', () => { diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index 587ecb01c64..80a85321933 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -12,7 +12,8 @@ import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool, ContextRef } from '../domain'; -import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; +import { ContextExternalToolService } from '../service'; +import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; import { ContextExternalToolDto } from './dto/context-external-tool.types'; @Injectable() 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 2fbd2f28edd..9aa0a11e448 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 @@ -12,7 +12,7 @@ import { ExternalToolService, ExternalToolServiceMapper, ExternalToolValidationService, - ExternalToolVersionService, + ExternalToolVersionIncrementService, } from './service'; import { CommonToolModule } from '../common'; @@ -23,7 +23,7 @@ import { CommonToolModule } from '../common'; ExternalToolServiceMapper, ExternalToolParameterValidationService, ExternalToolValidationService, - ExternalToolVersionService, + ExternalToolVersionIncrementService, ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolRepo, @@ -31,7 +31,7 @@ import { CommonToolModule } from '../common'; exports: [ ExternalToolService, ExternalToolValidationService, - ExternalToolVersionService, + ExternalToolVersionIncrementService, ExternalToolConfigurationService, ExternalToolLogoService, ], diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-version-increment.service.ts similarity index 98% rename from apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts rename to apps/server/src/modules/tool/external-tool/service/external-tool-version-increment.service.ts index c71e3b49b91..9e645b97ad2 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-version-increment.service.ts @@ -3,7 +3,7 @@ import { ExternalTool } from '../domain'; import { CustomParameter } from '../../common/domain'; @Injectable() -export class ExternalToolVersionService { +export class ExternalToolVersionIncrementService { increaseVersionOfNewToolIfNecessary(oldTool: ExternalTool, newTool: ExternalTool): void { if (!oldTool.parameters || !newTool.parameters) { return; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts index 91566609a8f..dabfd70996e 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-version.service.spec.ts @@ -1,14 +1,14 @@ import { customParameterFactory, externalToolFactory } from '@shared/testing/factory/domainobject/tool'; -import { ExternalToolVersionService } from './external-tool-version.service'; +import { ExternalToolVersionIncrementService } from './external-tool-version-increment.service'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '../../common/enum'; import { CustomParameter } from '../../common/domain'; import { ExternalTool } from '../domain'; describe('ExternalToolVersionService', () => { - let service: ExternalToolVersionService; + let service: ExternalToolVersionIncrementService; beforeEach(() => { - service = new ExternalToolVersionService(); + service = new ExternalToolVersionIncrementService(); }); const setup = () => { 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 ddb88ca4ff3..be146f30941 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 @@ -16,7 +16,7 @@ import { ExternalToolSearchQuery } from '../../common/interface'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; -import { ExternalToolVersionService } from './external-tool-version.service'; +import { ExternalToolVersionIncrementService } from './external-tool-version-increment.service'; import { ExternalToolService } from './external-tool.service'; describe('ExternalToolService', () => { @@ -29,7 +29,7 @@ describe('ExternalToolService', () => { let oauthProviderService: DeepMocked; let mapper: DeepMocked; let encryptionService: DeepMocked; - let versionService: DeepMocked; + let versionService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -64,8 +64,8 @@ describe('ExternalToolService', () => { useValue: createMock(), }, { - provide: ExternalToolVersionService, - useValue: createMock(), + provide: ExternalToolVersionIncrementService, + useValue: createMock(), }, ], }).compile(); @@ -77,7 +77,7 @@ describe('ExternalToolService', () => { oauthProviderService = module.get(OauthProviderService); mapper = module.get(ExternalToolServiceMapper); encryptionService = module.get(DefaultEncryptionService); - versionService = module.get(ExternalToolVersionService); + versionService = module.get(ExternalToolVersionIncrementService); }); afterAll(async () => { diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts index 0aa6181682c..d8481c28c71 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts @@ -10,7 +10,7 @@ import { ExternalToolSearchQuery } from '../../common/interface'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalTool, Oauth2ToolConfig } from '../domain'; import { ExternalToolServiceMapper } from './external-tool-service.mapper'; -import { ExternalToolVersionService } from './external-tool-version.service'; +import { ExternalToolVersionIncrementService } from './external-tool-version-increment.service'; @Injectable() export class ExternalToolService { @@ -22,7 +22,7 @@ export class ExternalToolService { private readonly contextExternalToolRepo: ContextExternalToolRepo, @Inject(DefaultEncryptionService) private readonly encryptionService: IEncryptionService, private readonly legacyLogger: LegacyLogger, - private readonly externalToolVersionService: ExternalToolVersionService + private readonly externalToolVersionService: ExternalToolVersionIncrementService ) {} async createExternalTool(externalTool: ExternalTool): Promise { diff --git a/apps/server/src/modules/tool/external-tool/service/index.ts b/apps/server/src/modules/tool/external-tool/service/index.ts index 8cb1df69bc1..f2290ca8969 100644 --- a/apps/server/src/modules/tool/external-tool/service/index.ts +++ b/apps/server/src/modules/tool/external-tool/service/index.ts @@ -1,6 +1,6 @@ export * from './external-tool.service'; export * from './external-tool-service.mapper'; -export * from './external-tool-version.service'; +export * from './external-tool-version-increment.service'; export * from './external-tool-validation.service'; export * from './external-tool-parameter-validation.service'; export * from './external-tool-configuration.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 3512e66038e..2cca9819b3a 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -1,9 +1,11 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, Permission, SchoolEntity, User } from '@shared/domain'; import { accountFactory, + customParameterEntityFactory, externalToolEntityFactory, schoolExternalToolEntityFactory, schoolFactory, @@ -11,9 +13,8 @@ import { UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ServerTestModule } from '@modules/server'; import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto/tool-configuration-status.response'; -import { ExternalToolEntity } from '../../../external-tool/entity'; +import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; import { SchoolExternalToolEntity } from '../../entity'; import { CustomParameterEntryParam, @@ -66,31 +67,31 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, - parameters: [], + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], }); - const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; const postParams: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, version: 1, - parameters: [paramEntry], - }; - - const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ - id: expect.any(String), - name: externalToolEntity.name, - schoolId: postParams.schoolId, - toolId: postParams.toolId, - status: ToolConfigurationStatusResponse.LATEST, - toolVersion: postParams.version, parameters: [ - { - name: paramEntry.name, - value: paramEntry.value, - }, + { name: 'param1', value: 'value' }, + { name: 'param2', value: '' }, ], - }); + }; em.persist([ school, @@ -112,7 +113,7 @@ describe('ToolSchoolController (API)', () => { loggedInClientWithMissingPermission, loggedInClient, postParams, - schoolExternalToolResponse, + externalToolEntity, }; }; @@ -125,12 +126,23 @@ describe('ToolSchoolController (API)', () => { }); it('should create an school external tool', async () => { - const { loggedInClient, postParams, schoolExternalToolResponse } = await setup(); + const { loggedInClient, postParams, externalToolEntity } = await setup(); const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(response.body).toEqual(schoolExternalToolResponse); + expect(response.body).toEqual({ + id: expect.any(String), + name: externalToolEntity.name, + schoolId: postParams.schoolId, + toolId: postParams.toolId, + status: ToolConfigurationStatusResponse.LATEST, + toolVersion: postParams.version, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: undefined }, + ], + }); const createdSchoolExternalTool: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { school: postParams.schoolId, @@ -391,7 +403,14 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, - parameters: [], + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + ], }); const externalToolEntity2: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, @@ -422,7 +441,7 @@ describe('ToolSchoolController (API)', () => { accountWithMissingPermission ); - const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; + const paramEntry: CustomParameterEntryParam = { name: 'param1', value: 'value' }; const postParams: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, @@ -430,7 +449,7 @@ describe('ToolSchoolController (API)', () => { parameters: [paramEntry], }; - const updatedParamEntry: CustomParameterEntryParam = { name: 'name', value: 'updatedValue' }; + const updatedParamEntry: CustomParameterEntryParam = { name: 'param1', value: 'updatedValue' }; const postParamsUpdate: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, 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 6617e4ec7fd..eff05c092cb 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,7 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { CustomParameterEntry } from '../../common/domain'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; -import { CustomParameterEntry } from '../../common/domain'; @Injectable() export class SchoolExternalToolRequestMapper { @@ -20,7 +20,7 @@ export class SchoolExternalToolRequestMapper { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, - value: customParameterParam.value, + value: customParameterParam.value || undefined, }; }); } 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 790f4e716c2..898e159348a 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 @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { CommonToolModule } from '../common'; import { SchoolExternalToolService, SchoolExternalToolValidationService } from './service'; import { ExternalToolModule } from '../external-tool'; +import { ToolConfigModule } from '../tool-config.module'; @Module({ - imports: [CommonToolModule, ExternalToolModule], + imports: [CommonToolModule, ExternalToolModule, ToolConfigModule], providers: [SchoolExternalToolService, SchoolExternalToolValidationService], exports: [SchoolExternalToolService, SchoolExternalToolValidationService], }) diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts index e43bdeb42e0..1f2ba7f5eb9 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts @@ -4,6 +4,7 @@ import { externalToolFactory, schoolExternalToolFactory } from '@shared/testing/ import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; @@ -13,6 +14,7 @@ describe('SchoolExternalToolValidationService', () => { let externalToolService: DeepMocked; let commonToolValidationService: DeepMocked; + let toolFeatures: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +28,19 @@ describe('SchoolExternalToolValidationService', () => { provide: CommonToolValidationService, useValue: createMock(), }, + { + provide: ToolFeatures, + useValue: { + toolStatusWithoutVersions: false, + }, + }, ], }).compile(); service = module.get(SchoolExternalToolValidationService); externalToolService = module.get(ExternalToolService); commonToolValidationService = module.get(CommonToolValidationService); + toolFeatures = module.get(ToolFeatures); }); afterEach(() => { @@ -51,8 +60,12 @@ describe('SchoolExternalToolValidationService', () => { ...externalToolFactory.buildWithId(), ...externalToolDoMock, }); - externalToolService.findById.mockResolvedValue(externalTool); + const schoolExternalToolId = schoolExternalTool.id as string; + + externalToolService.findById.mockResolvedValue(externalTool); + toolFeatures.toolStatusWithoutVersions = true; + return { schoolExternalTool, ExternalTool, @@ -69,14 +82,6 @@ describe('SchoolExternalToolValidationService', () => { expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); - it('should call commonToolValidationService.checkForDuplicateParameters', async () => { - const { schoolExternalTool } = setup(); - - await service.validate(schoolExternalTool); - - expect(commonToolValidationService.checkForDuplicateParameters).toHaveBeenCalledWith(schoolExternalTool); - }); - it('should call commonToolValidationService.checkCustomParameterEntries', async () => { const { schoolExternalTool } = setup(); @@ -87,9 +92,45 @@ describe('SchoolExternalToolValidationService', () => { schoolExternalTool ); }); + + it('should not throw error', async () => { + const { schoolExternalTool } = setup({ version: 8383 }, { toolVersion: 1337 }); + + const func = () => service.validate(schoolExternalTool); + + await expect(func()).resolves.not.toThrow(); + }); }); + }); + // TODO N21-1337 refactor after feature flag is removed + describe('validate with FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED on false', () => { describe('when version of externalTool and schoolExternalTool are different', () => { + const setup = ( + externalToolDoMock?: Partial, + schoolExternalToolDoMock?: Partial + ) => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + ...schoolExternalToolFactory.buildWithId(), + ...schoolExternalToolDoMock, + }); + const externalTool: ExternalTool = new ExternalTool({ + ...externalToolFactory.buildWithId(), + ...externalToolDoMock, + }); + + const schoolExternalToolId = schoolExternalTool.id as string; + + externalToolService.findById.mockResolvedValue(externalTool); + toolFeatures.toolStatusWithoutVersions = false; + + return { + schoolExternalTool, + ExternalTool, + schoolExternalToolId, + }; + }; + it('should throw error', async () => { const { schoolExternalTool } = setup({ version: 8383 }, { toolVersion: 1337 }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts index 315d738ca64..899055e321f 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts @@ -1,23 +1,25 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { SchoolExternalTool } from '../domain'; @Injectable() export class SchoolExternalToolValidationService { constructor( private readonly externalToolService: ExternalToolService, - private readonly commonToolValidationService: CommonToolValidationService + private readonly commonToolValidationService: CommonToolValidationService, + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures ) {} async validate(schoolExternalTool: SchoolExternalTool): Promise { - this.commonToolValidationService.checkForDuplicateParameters(schoolExternalTool); - const loadedExternalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); - this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); + if (!this.toolFeatures.toolStatusWithoutVersions) { + this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); + } this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, schoolExternalTool); } 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 52f9b0a4c02..b8c2789322d 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 @@ -3,11 +3,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SchoolExternalToolRepo } from '@shared/repo'; import { externalToolFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { schoolExternalToolFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; +import { ApiValidationError } from '@shared/common'; import { ToolConfigurationStatus } from '../../common/enum'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolService } from './school-external-tool.service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; +import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; describe('SchoolExternalToolService', () => { let module: TestingModule; @@ -15,6 +18,8 @@ describe('SchoolExternalToolService', () => { let schoolExternalToolRepo: DeepMocked; let externalToolService: DeepMocked; + let schoolExternalToolValidationService: DeepMocked; + let toolFearures: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -28,19 +33,33 @@ describe('SchoolExternalToolService', () => { provide: ExternalToolService, useValue: createMock(), }, + { + provide: SchoolExternalToolValidationService, + useValue: createMock(), + }, + { + provide: ToolFeatures, + useValue: { + toolStatusWithoutVersions: false, + }, + }, ], }).compile(); service = module.get(SchoolExternalToolService); schoolExternalToolRepo = module.get(SchoolExternalToolRepo); externalToolService = module.get(ExternalToolService); + schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); + toolFearures = module.get(ToolFeatures); }); - const setup = () => { + // TODO N21-1337 refactor setup into the describe blocks + const legacySetup = () => { const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); const externalTool: ExternalTool = externalToolFactory.buildWithId(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + toolFearures.toolStatusWithoutVersions = false; return { schoolExternalTool, @@ -52,7 +71,7 @@ describe('SchoolExternalToolService', () => { describe('findSchoolExternalTools', () => { describe('when called with query', () => { it('should call repo with query', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool } = legacySetup(); await service.findSchoolExternalTools(schoolExternalTool); @@ -60,7 +79,7 @@ describe('SchoolExternalToolService', () => { }); it('should return schoolExternalTool array', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool } = legacySetup(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool, schoolExternalTool]); const result: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); @@ -72,7 +91,7 @@ describe('SchoolExternalToolService', () => { describe('enrichDataFromExternalTool', () => { it('should call the externalToolService', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool } = legacySetup(); schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); await service.findSchoolExternalTools(schoolExternalTool); @@ -81,44 +100,102 @@ describe('SchoolExternalToolService', () => { }); describe('when determine status', () => { - describe('when external tool version is greater', () => { - it('should return status outdated', async () => { - const { schoolExternalTool, externalTool } = setup(); - externalTool.version = 1337; - schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findById.mockResolvedValue(externalTool); + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is false', () => { + describe('when external tool version is greater', () => { + it('should return status outdated', async () => { + const { schoolExternalTool, externalTool } = legacySetup(); + externalTool.version = 1337; + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( + schoolExternalTool + ); + + expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.OUTDATED); + }); + }); - const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + describe('when external tool version is lower', () => { + it('should return status latest', async () => { + const { schoolExternalTool, externalTool } = legacySetup(); + schoolExternalTool.toolVersion = 1; + externalTool.version = 0; + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); - expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.OUTDATED); + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( + schoolExternalTool + ); + + expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.LATEST); + }); + }); + + describe('when external tool version is equal', () => { + it('should return status latest', async () => { + const { schoolExternalTool, externalTool } = legacySetup(); + schoolExternalTool.toolVersion = 1; + externalTool.version = 1; + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools( + schoolExternalTool + ); + + expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.LATEST); + }); }); }); - describe('when external tool version is lower', () => { - it('should return status latest', async () => { - const { schoolExternalTool, externalTool } = setup(); - schoolExternalTool.toolVersion = 1; - externalTool.version = 0; + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and 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(); + toolFearures.toolStatusWithoutVersions = true; + + 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(ToolConfigurationStatus.LATEST); }); }); - describe('when external tool version is equal', () => { - it('should return status latest', async () => { - const { schoolExternalTool, externalTool } = setup(); - schoolExternalTool.toolVersion = 1; - externalTool.version = 1; + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and 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); + toolFearures.toolStatusWithoutVersions = true; + + return { + schoolExternalTool, + }; + }; + + it('should return outdated tool status', async () => { + const { schoolExternalTool } = setup(); const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); - expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.LATEST); + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(schoolExternalToolDOs[0].status).toEqual(ToolConfigurationStatus.OUTDATED); }); }); }); @@ -127,7 +204,7 @@ describe('SchoolExternalToolService', () => { describe('deleteSchoolExternalToolById', () => { describe('when schoolExternalToolId is given', () => { it('should call the schoolExternalToolRepo', async () => { - const { schoolExternalToolId } = setup(); + const { schoolExternalToolId } = legacySetup(); await service.deleteSchoolExternalToolById(schoolExternalToolId); @@ -139,7 +216,7 @@ describe('SchoolExternalToolService', () => { describe('findById', () => { describe('when schoolExternalToolId is given', () => { it('should call schoolExternalToolRepo.findById', async () => { - const { schoolExternalToolId } = setup(); + const { schoolExternalToolId } = legacySetup(); await service.findById(schoolExternalToolId); @@ -151,7 +228,7 @@ describe('SchoolExternalToolService', () => { describe('saveSchoolExternalTool', () => { describe('when schoolExternalTool is given', () => { it('should call schoolExternalToolRepo.save', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool } = legacySetup(); await service.saveSchoolExternalTool(schoolExternalTool); @@ -159,7 +236,7 @@ describe('SchoolExternalToolService', () => { }); it('should enrich data from externalTool', async () => { - const { schoolExternalTool } = setup(); + const { schoolExternalTool } = legacySetup(); await service.saveSchoolExternalTool(schoolExternalTool); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index 2f011560f6a..e654f82f96a 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,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { SchoolExternalToolRepo } from '@shared/repo'; import { ToolConfigurationStatus } from '../../common/enum'; @@ -6,12 +6,16 @@ import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; @Injectable() export class SchoolExternalToolService { constructor( private readonly schoolExternalToolRepo: SchoolExternalToolRepo, - private readonly externalToolService: ExternalToolService + private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures ) {} async findById(schoolExternalToolId: EntityId): Promise { @@ -39,7 +43,7 @@ export class SchoolExternalToolService { private async enrichDataFromExternalTool(tool: SchoolExternalTool): Promise { const externalTool: ExternalTool = await this.externalToolService.findById(tool.toolId); - const status: ToolConfigurationStatus = this.determineStatus(tool, externalTool); + const status: ToolConfigurationStatus = await this.determineStatus(tool, externalTool); const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ ...tool, status, @@ -49,7 +53,19 @@ export class SchoolExternalToolService { return schoolExternalTool; } - private determineStatus(tool: SchoolExternalTool, externalTool: ExternalTool): ToolConfigurationStatus { + private async determineStatus( + tool: SchoolExternalTool, + externalTool: ExternalTool + ): Promise { + if (this.toolFeatures.toolStatusWithoutVersions) { + try { + await this.schoolExternalToolValidationService.validate(tool); + return ToolConfigurationStatus.LATEST; + } catch (err) { + return ToolConfigurationStatus.OUTDATED; + } + } + if (externalTool.version <= tool.toolVersion) { return ToolConfigurationStatus.LATEST; } diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index 2bd47089286..1405f0c7a1d 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -6,6 +6,8 @@ export interface IToolFeatures { ctlToolsTabEnabled: boolean; ltiToolsTabEnabled: boolean; contextConfigurationEnabled: boolean; + // TODO N21-1337 refactor after feature flag is removed + toolStatusWithoutVersions: boolean; maxExternalToolLogoSizeInBytes: number; backEndUrl: string; } @@ -15,6 +17,8 @@ export default class ToolConfiguration { ctlToolsTabEnabled: Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED') as boolean, ltiToolsTabEnabled: Configuration.get('FEATURE_LTI_TOOLS_TAB_ENABLED') as boolean, contextConfigurationEnabled: Configuration.get('FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED') as boolean, + // TODO N21-1337 refactor after feature flag is removed + toolStatusWithoutVersions: Configuration.get('FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED') as boolean, maxExternalToolLogoSizeInBytes: Configuration.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') as number, backEndUrl: Configuration.get('PUBLIC_BACKEND_URL') as string, }; diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index e4f9eaa6113..16e8e093e9e 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -8,7 +8,6 @@ import { schoolExternalToolFactory, } from '@shared/testing'; import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { BasicToolConfig, ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; @@ -23,6 +22,7 @@ import { OAuth2ToolLaunchStrategy, } from './launch-strategy'; import { ToolLaunchService } from './tool-launch.service'; +import { ToolVersionService } from '../../context-external-tool/service/tool-version-service'; describe('ToolLaunchService', () => { let module: TestingModule; @@ -31,7 +31,7 @@ describe('ToolLaunchService', () => { let schoolExternalToolService: DeepMocked; let externalToolService: DeepMocked; let basicToolLaunchStrategy: DeepMocked; - let commonToolService: DeepMocked; + let toolVersionService: DeepMocked; beforeEach(async () => { module = await Test.createTestingModule({ @@ -58,8 +58,8 @@ describe('ToolLaunchService', () => { useValue: createMock(), }, { - provide: CommonToolService, - useValue: createMock(), + provide: ToolVersionService, + useValue: createMock(), }, ], }).compile(); @@ -68,7 +68,7 @@ describe('ToolLaunchService', () => { schoolExternalToolService = module.get(SchoolExternalToolService); externalToolService = module.get(ExternalToolService); basicToolLaunchStrategy = module.get(BasicToolLaunchStrategy); - commonToolService = module.get(CommonToolService); + toolVersionService = module.get(ToolVersionService); }); afterAll(async () => { @@ -107,7 +107,7 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); + toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce(ToolConfigurationStatus.LATEST); return { launchDataDO, @@ -146,6 +146,14 @@ describe('ToolLaunchService', () => { expect(externalToolService.findById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); }); + + it('should call toolVersionService', async () => { + const { launchParams } = setup(); + + await service.getLaunchData('userId', launchParams.contextExternalTool); + + expect(toolVersionService.determineToolConfigurationStatus).toHaveBeenCalled(); + }); }); describe('when the tool config type is unknown', () => { @@ -165,7 +173,7 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); - commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); + toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce(ToolConfigurationStatus.LATEST); return { launchParams, @@ -210,10 +218,9 @@ describe('ToolLaunchService', () => { schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.OUTDATED); + toolVersionService.determineToolConfigurationStatus.mockResolvedValueOnce(ToolConfigurationStatus.OUTDATED); return { - launchDataDO, launchParams, userId, contextExternalToolId: contextExternalTool.id as string, diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index abb5598796f..5b090f9778a 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -1,7 +1,6 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; @@ -16,6 +15,7 @@ import { Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy, } from './launch-strategy'; +import { ToolVersionService } from '../../context-external-tool/service/tool-version-service'; @Injectable() export class ToolLaunchService { @@ -27,7 +27,7 @@ export class ToolLaunchService { private readonly basicToolLaunchStrategy: BasicToolLaunchStrategy, private readonly lti11ToolLaunchStrategy: Lti11ToolLaunchStrategy, private readonly oauth2ToolLaunchStrategy: OAuth2ToolLaunchStrategy, - private readonly commonToolService: CommonToolService + private readonly toolVersionService: ToolVersionService ) { this.strategies = new Map(); this.strategies.set(ToolConfigType.BASIC, basicToolLaunchStrategy); @@ -53,7 +53,7 @@ export class ToolLaunchService { const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - this.isToolStatusLatestOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); + await this.isToolStatusLatestOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); const strategy: IToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); @@ -83,17 +83,18 @@ export class ToolLaunchService { }; } - private isToolStatusLatestOrThrow( + private async isToolStatusLatestOrThrow( userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, contextExternalTool: ContextExternalTool - ): void { - const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( + ): Promise { + const status = await this.toolVersionService.determineToolConfigurationStatus( externalTool, schoolExternalTool, contextExternalTool ); + if (status !== ToolConfigurationStatus.LATEST) { throw new ToolStatusOutdatedLoggableException(userId, contextExternalTool.id ?? ''); } 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 154148c1fa0..f23795a35ab 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 @@ -688,7 +688,11 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([teacherAccount, teacherUser]); + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school: teacherUser.school, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, userLoginMigration]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -1156,9 +1160,22 @@ describe('UserLoginMigrationController (API)', () => { closedAt: new Date(2023, 1, 5), }); + const migratedUser = userFactory.buildWithId({ + school, + lastLoginSystemChange: new Date(2023, 1, 4), + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); - await em.persistAndFlush([sourceSystem, targetSystem, school, adminAccount, adminUser, userLoginMigration]); + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + adminAccount, + adminUser, + userLoginMigration, + migratedUser, + ]); em.clear(); const loggedInClient = await testApiClient.login(adminAccount); diff --git a/apps/server/src/modules/user-login-migration/controller/dto/index.ts b/apps/server/src/modules/user-login-migration/controller/dto/index.ts index a158f06bb75..bcbb7e46f4e 100644 --- a/apps/server/src/modules/user-login-migration/controller/dto/index.ts +++ b/apps/server/src/modules/user-login-migration/controller/dto/index.ts @@ -1,5 +1,3 @@ export * from './request/user-login-migration-search.params'; -export * from './request/page-type.query.param'; export * from './response/user-login-migration.response'; export * from './response/user-login-migration-search-list.response'; -export * from './response/page-content.response'; diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/page-type.query.param.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/page-type.query.param.ts deleted file mode 100644 index 6c755052944..00000000000 --- a/apps/server/src/modules/user-login-migration/controller/dto/request/page-type.query.param.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsEnum, IsMongoId } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { PageTypes } from '../../../interface/page-types.enum'; - -export class PageContentQueryParams { - @ApiProperty({ description: 'The Type of Page that is displayed', type: PageTypes }) - @IsEnum(PageTypes) - pageType!: PageTypes; - - @ApiProperty({ description: 'The Source System' }) - @IsMongoId() - sourceSystem!: string; - - @ApiProperty({ description: 'The Target System' }) - @IsMongoId() - targetSystem!: string; -} diff --git a/apps/server/src/modules/user-login-migration/controller/dto/response/page-content.response.ts b/apps/server/src/modules/user-login-migration/controller/dto/response/page-content.response.ts deleted file mode 100644 index 836412b64c7..00000000000 --- a/apps/server/src/modules/user-login-migration/controller/dto/response/page-content.response.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class PageContentResponse { - @ApiProperty({ - description: 'The URL for the proceed button', - }) - proceedButtonUrl: string; - - @ApiProperty({ - description: 'The URL for the cancel button', - }) - cancelButtonUrl: string; - - constructor(props: PageContentResponse) { - this.proceedButtonUrl = props.proceedButtonUrl; - this.cancelButtonUrl = props.cancelButtonUrl; - } -} 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 fc1c9c9f9cf..5489e33e3a4 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 @@ -17,7 +17,7 @@ import { UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationGracePeriodExpiredLoggableException, UserLoginMigrationNotFoundLoggableException, -} from '../error'; +} from '../loggable'; import { UserLoginMigrationMapper } from '../mapper'; import { CloseUserLoginMigrationUc, diff --git a/apps/server/src/modules/user-login-migration/controller/user-migration.controller.spec.ts b/apps/server/src/modules/user-login-migration/controller/user-migration.controller.spec.ts deleted file mode 100644 index 5307fa5ac2f..00000000000 --- a/apps/server/src/modules/user-login-migration/controller/user-migration.controller.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { PageTypes } from '../interface/page-types.enum'; -import { PageContentMapper } from '../mapper'; -import { PageContentDto } from '../service/dto'; -import { UserLoginMigrationUc } from '../uc/user-login-migration.uc'; -import { PageContentQueryParams, PageContentResponse } from './dto'; -import { UserMigrationController } from './user-migration.controller'; - -describe('MigrationController', () => { - let module: TestingModule; - let controller: UserMigrationController; - let uc: DeepMocked; - let mapper: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - { - provide: UserLoginMigrationUc, - useValue: createMock(), - }, - { - provide: PageContentMapper, - useValue: createMock(), - }, - ], - controllers: [UserMigrationController], - }).compile(); - - controller = module.get(UserMigrationController); - uc = module.get(UserLoginMigrationUc); - mapper = module.get(PageContentMapper); - }); - afterAll(async () => { - await module.close(); - }); - - const setup = () => { - const query: PageContentQueryParams = { - pageType: PageTypes.START_FROM_TARGET_SYSTEM, - sourceSystem: 'source', - targetSystem: 'target', - }; - const dto: PageContentDto = new PageContentDto({ - proceedButtonUrl: 'proceedUrl', - cancelButtonUrl: 'cancelUrl', - }); - const response: PageContentResponse = new PageContentResponse({ - proceedButtonUrl: 'proceedUrl', - cancelButtonUrl: 'cancelUrl', - }); - return { query, dto, response }; - }; - - describe('getMigrationPageDetails is called', () => { - describe('when pagecontent is requested', () => { - it('should return a response', async () => { - const { query, dto, response } = setup(); - mapper.mapDtoToResponse.mockReturnValue(response); - uc.getPageContent.mockResolvedValue(dto); - const testResp: PageContentResponse = await controller.getMigrationPageDetails(query); - expect(testResp).toEqual(response); - }); - }); - }); -}); diff --git a/apps/server/src/modules/user-login-migration/controller/user-migration.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-migration.controller.ts deleted file mode 100644 index c3afc626ed9..00000000000 --- a/apps/server/src/modules/user-login-migration/controller/user-migration.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { PageContentMapper } from '../mapper'; -import { PageContentDto } from '../service/dto'; -import { UserLoginMigrationUc } from '../uc/user-login-migration.uc'; -import { PageContentQueryParams, PageContentResponse } from './dto'; - -@ApiTags('UserMigration') -@Controller('user-migration') -/** - * @Deprecated - */ -export class UserMigrationController { - constructor(private readonly uc: UserLoginMigrationUc, private readonly pageContentMapper: PageContentMapper) {} - - @Get('page-content') - async getMigrationPageDetails(@Query() pageTypeQuery: PageContentQueryParams): Promise { - const content: PageContentDto = await this.uc.getPageContent( - pageTypeQuery.pageType, - pageTypeQuery.sourceSystem, - pageTypeQuery.targetSystem - ); - - const response: PageContentResponse = this.pageContentMapper.mapDtoToResponse(content); - return response; - } -} diff --git a/apps/server/src/modules/user-login-migration/error/index.ts b/apps/server/src/modules/user-login-migration/error/index.ts deleted file mode 100644 index e5ac9f5f970..00000000000 --- a/apps/server/src/modules/user-login-migration/error/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './oauth-migration.error'; -export * from './school-migration.error'; -export * from './user-login-migration.error'; -export * from './school-number-missing.loggable-exception'; -export * from './user-login-migration-already-closed.loggable-exception'; -export * from './user-login-migration-grace-period-expired-loggable.exception'; -export * from './user-login-migration-not-found.loggable-exception'; diff --git a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.spec.ts b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.spec.ts deleted file mode 100644 index d42801c4287..00000000000 --- a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { OAuthMigrationError } from './oauth-migration.error'; - -describe('Oauth Migration Error', () => { - it('should be possible to create', () => { - const error = new OAuthMigrationError(); - expect(error).toBeDefined(); - expect(error.message).toEqual(error.DEFAULT_MESSAGE); - expect(error.errorcode).toEqual(error.DEFAULT_ERRORCODE); - }); - - it('should be possible to add message', () => { - const msg = 'test message'; - const error = new OAuthMigrationError(msg); - expect(error.message).toEqual(msg); - }); - - it('should have the right code', () => { - const errCode = 'test_code'; - const error = new OAuthMigrationError('', errCode); - expect(error.errorcode).toEqual(errCode); - }); - - it('should create with specific parameter', () => { - const error = new OAuthMigrationError(undefined, undefined, '12345', '11111'); - expect(error).toBeDefined(); - expect(error.message).toEqual(error.DEFAULT_MESSAGE); - expect(error.errorcode).toEqual(error.DEFAULT_ERRORCODE); - expect(error.officialSchoolNumberFromSource).toEqual('12345'); - expect(error.officialSchoolNumberFromTarget).toEqual('11111'); - }); -}); diff --git a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts deleted file mode 100644 index c21185f1c93..00000000000 --- a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { OAuthSSOError } from '@modules/oauth/loggable'; - -export class OAuthMigrationError extends OAuthSSOError { - readonly message: string; - - readonly errorcode: string; - - readonly DEFAULT_MESSAGE: string = 'Error in Oauth Migration Process.'; - - readonly DEFAULT_ERRORCODE: string = 'OauthMigrationFailed'; - - readonly officialSchoolNumberFromSource?: string; - - readonly officialSchoolNumberFromTarget?: string; - - constructor( - message?: string, - errorcode?: string, - officialSchoolNumberFromSource?: string, - officialSchoolNumberFromTarget?: string - ) { - super(message); - this.message = message || this.DEFAULT_MESSAGE; - this.errorcode = errorcode || this.DEFAULT_ERRORCODE; - this.officialSchoolNumberFromSource = officialSchoolNumberFromSource; - this.officialSchoolNumberFromTarget = officialSchoolNumberFromTarget; - } -} diff --git a/apps/server/src/modules/user-login-migration/error/school-migration.error.ts b/apps/server/src/modules/user-login-migration/error/school-migration.error.ts deleted file mode 100644 index 3887b693f49..00000000000 --- a/apps/server/src/modules/user-login-migration/error/school-migration.error.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; -import { BusinessError } from '@shared/common'; - -export class SchoolMigrationError extends BusinessError { - constructor(details?: Record, cause?: unknown) { - super( - { - type: 'SCHOOL_MIGRATION_FAILED', - title: 'Migration of school failed.', - defaultMessage: 'School could not migrate during user migration process.', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - details, - cause - ); - } -} diff --git a/apps/server/src/modules/user-login-migration/error/user-login-migration.error.ts b/apps/server/src/modules/user-login-migration/error/user-login-migration.error.ts deleted file mode 100644 index 29239fc817c..00000000000 --- a/apps/server/src/modules/user-login-migration/error/user-login-migration.error.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; -import { BusinessError } from '@shared/common'; - -export class UserLoginMigrationError extends BusinessError { - constructor(details?: Record) { - super( - { - type: 'USER_MIGRATION_FAILED', - title: 'Migration failed', - defaultMessage: 'Migration of user failed during migration process', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - details - ); - } -} diff --git a/apps/server/src/modules/user-login-migration/index.ts b/apps/server/src/modules/user-login-migration/index.ts index c357ceaf98e..c3841ebf249 100644 --- a/apps/server/src/modules/user-login-migration/index.ts +++ b/apps/server/src/modules/user-login-migration/index.ts @@ -1,3 +1,2 @@ export * from './user-login-migration.module'; export * from './service'; -export * from './error'; diff --git a/apps/server/src/modules/user-login-migration/interface/page-types.enum.ts b/apps/server/src/modules/user-login-migration/interface/page-types.enum.ts deleted file mode 100644 index 87848b71e31..00000000000 --- a/apps/server/src/modules/user-login-migration/interface/page-types.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum PageTypes { - START_FROM_TARGET_SYSTEM = 'start_from_target_system', - START_FROM_SOURCE_SYSTEM = 'start_from_source_system', - START_FROM_SOURCE_SYSTEM_MANDATORY = 'start_from_source_system_mandatory', -} diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/index.ts b/apps/server/src/modules/user-login-migration/loggable/debug/index.ts new file mode 100644 index 00000000000..cf5d1274646 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/index.ts @@ -0,0 +1,3 @@ +export * from './school-migration-successful.loggable'; +export * from './user-migration-started.loggable'; +export * from './user-migration-successful.loggable'; diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.spec.ts new file mode 100644 index 00000000000..d5e8d7407f6 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.spec.ts @@ -0,0 +1,42 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { legacySchoolDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { SchoolMigrationSuccessfulLoggable } from './school-migration-successful.loggable'; + +describe(SchoolMigrationSuccessfulLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const school = legacySchoolDoFactory.build({ + id: new ObjectId().toHexString(), + externalId: 'externalId', + previousExternalId: 'previousExternalId', + }); + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + }); + + const exception = new SchoolMigrationSuccessfulLoggable(school, userLoginMigration); + + return { + exception, + school, + userLoginMigration, + }; + }; + + it('should return the correct log message', () => { + const { exception, school, userLoginMigration } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + message: 'A school has successfully migrated.', + data: { + schoolId: school.id, + externalId: school.externalId, + previousExternalId: school.previousExternalId, + userLoginMigrationId: userLoginMigration.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.ts new file mode 100644 index 00000000000..2614ae24c72 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.ts @@ -0,0 +1,18 @@ +import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain'; +import { Loggable, LogMessage } from '@src/core/logger'; + +export class SchoolMigrationSuccessfulLoggable implements Loggable { + constructor(private readonly school: LegacySchoolDo, private readonly userLoginMigration: UserLoginMigrationDO) {} + + getLogMessage(): LogMessage { + return { + message: 'A school has successfully migrated.', + data: { + schoolId: this.school.id, + externalId: this.school.externalId, + previousExternalId: this.school.previousExternalId, + userLoginMigrationId: this.userLoginMigration.id, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.spec.ts new file mode 100644 index 00000000000..22c2ded1b67 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.spec.ts @@ -0,0 +1,34 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { userLoginMigrationDOFactory } from '@shared/testing'; +import { UserMigrationStartedLoggable } from './user-migration-started.loggable'; + +describe(UserMigrationStartedLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + + const exception = new UserMigrationStartedLoggable(userId, userLoginMigration); + + return { + exception, + userId, + userLoginMigration, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId, userLoginMigration } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + message: 'A user started the user login migration.', + data: { + userId, + userLoginMigrationId: userLoginMigration.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.ts new file mode 100644 index 00000000000..49c2b8c4813 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.ts @@ -0,0 +1,16 @@ +import { EntityId, UserLoginMigrationDO } from '@shared/domain'; +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UserMigrationStartedLoggable implements Loggable { + constructor(private readonly userId: EntityId, private readonly userLoginMigration: UserLoginMigrationDO) {} + + getLogMessage(): LogMessage { + return { + message: 'A user started the user login migration.', + data: { + userId: this.userId, + userLoginMigrationId: this.userLoginMigration.id, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.spec.ts new file mode 100644 index 00000000000..aae8baf96d9 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.spec.ts @@ -0,0 +1,34 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { userLoginMigrationDOFactory } from '@shared/testing'; +import { UserMigrationSuccessfulLoggable } from './user-migration-successful.loggable'; + +describe(UserMigrationSuccessfulLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); + + const exception = new UserMigrationSuccessfulLoggable(userId, userLoginMigration); + + return { + exception, + userId, + userLoginMigration, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId, userLoginMigration } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + message: 'A user has successfully migrated.', + data: { + userId, + userLoginMigrationId: userLoginMigration.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.ts new file mode 100644 index 00000000000..d61259816c5 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.ts @@ -0,0 +1,16 @@ +import { EntityId, UserLoginMigrationDO } from '@shared/domain'; +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UserMigrationSuccessfulLoggable implements Loggable { + constructor(private readonly userId: EntityId, private readonly userLoginMigration: UserLoginMigrationDO) {} + + getLogMessage(): LogMessage { + return { + message: 'A user has successfully migrated.', + data: { + userId: this.userId, + userLoginMigrationId: this.userLoginMigration.id, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..0819925d7d8 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { ExternalSchoolNumberMissingLoggableException } from './external-school-number-missing.loggable-exception'; + +describe(ExternalSchoolNumberMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const externalSchoolId = 'externalSchoolId'; + const exception = new ExternalSchoolNumberMissingLoggableException(externalSchoolId); + + return { + exception, + externalSchoolId, + }; + }; + + it('should return the correct log message', () => { + const { exception, externalSchoolId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_SCHOOL_NUMBER_MISSING', + message: 'The external system did not provide a official school number for the school.', + stack: expect.any(String), + data: { + externalSchoolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.ts new file mode 100644 index 00000000000..6c94a05e2e5 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.ts @@ -0,0 +1,19 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ExternalSchoolNumberMissingLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly externalSchoolId: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'EXTERNAL_SCHOOL_NUMBER_MISSING', + message: 'The external system did not provide a official school number for the school.', + stack: this.stack, + data: { + externalSchoolId: this.externalSchoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/index.ts b/apps/server/src/modules/user-login-migration/loggable/index.ts index 28cdc9f6baa..e31de36b72a 100644 --- a/apps/server/src/modules/user-login-migration/loggable/index.ts +++ b/apps/server/src/modules/user-login-migration/loggable/index.ts @@ -1,2 +1,12 @@ export * from './user-login-migration-start.loggable'; export * from './user-login-migration-mandatory.loggable'; +export * from './school-number-missing.loggable-exception'; +export * from './user-login-migration-already-closed.loggable-exception'; +export * from './user-login-migration-grace-period-expired-loggable.exception'; +export * from './user-login-migration-not-found.loggable-exception'; +export * from './school-number-mismatch.loggable-exception'; +export * from './external-school-number-missing.loggable-exception'; +export * from './user-migration-database-operation-failed.loggable-exception'; +export * from './school-migration-database-operation-failed.loggable-exception'; +export * from './invalid-user-login-migration.loggable-exception'; +export * from './debug'; diff --git a/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.spec.ts new file mode 100644 index 00000000000..b0f50611c29 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { InvalidUserLoginMigrationLoggableException } from './invalid-user-login-migration.loggable-exception'; + +describe(InvalidUserLoginMigrationLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const targetSystemId = new ObjectId().toHexString(); + + const exception = new InvalidUserLoginMigrationLoggableException(userId, targetSystemId); + + return { + exception, + userId, + targetSystemId, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId, targetSystemId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_USER_LOGIN_MIGRATION', + message: 'The migration cannot be started, because there is no migration to the selected target system.', + stack: expect.any(String), + data: { + userId, + targetSystemId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.ts new file mode 100644 index 00000000000..8e24483de1b --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.ts @@ -0,0 +1,21 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class InvalidUserLoginMigrationLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly userId: EntityId, private readonly targetSystemId: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'INVALID_USER_LOGIN_MIGRATION', + message: 'The migration cannot be started, because there is no migration to the selected target system.', + stack: this.stack, + data: { + userId: this.userId, + targetSystemId: this.targetSystemId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.spec.ts new file mode 100644 index 00000000000..701b5a6a4fe --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { legacySchoolDoFactory } from '@shared/testing'; +import { SchoolMigrationDatabaseOperationFailedLoggableException } from './school-migration-database-operation-failed.loggable-exception'; + +describe(SchoolMigrationDatabaseOperationFailedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const school = legacySchoolDoFactory.buildWithId(); + + const exception = new SchoolMigrationDatabaseOperationFailedLoggableException(school, 'migration', new Error()); + + return { + exception, + school, + }; + }; + + it('should return the correct log message', () => { + const { exception, school } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'SCHOOL_LOGIN_MIGRATION_DATABASE_OPERATION_FAILED', + stack: expect.any(String), + data: { + schoolId: school.id, + operation: 'migration', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.ts new file mode 100644 index 00000000000..b31e2663701 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.ts @@ -0,0 +1,29 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { LegacySchoolDo } from '@shared/domain'; +import { ErrorUtils } from '@src/core/error/utils'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class SchoolMigrationDatabaseOperationFailedLoggableException + extends InternalServerErrorException + implements Loggable +{ + // TODO: Remove undefined type from schoolId when using the new School DO + constructor( + private readonly school: LegacySchoolDo, + private readonly operation: 'migration' | 'rollback', + error: unknown + ) { + super(ErrorUtils.createHttpExceptionOptions(error)); + } + + public getLogMessage(): ErrorLogMessage { + return { + type: 'SCHOOL_LOGIN_MIGRATION_DATABASE_OPERATION_FAILED', + stack: this.stack, + data: { + schoolId: this.school.id, + operation: this.operation, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.spec.ts new file mode 100644 index 00000000000..caa39202746 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.spec.ts @@ -0,0 +1,34 @@ +import { SchoolNumberMismatchLoggableException } from './school-number-mismatch.loggable-exception'; + +describe(SchoolNumberMismatchLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const sourceSchoolNumber = '123'; + const targetSchoolNumber = '456'; + + const exception = new SchoolNumberMismatchLoggableException(sourceSchoolNumber, targetSchoolNumber); + + return { + exception, + sourceSchoolNumber, + targetSchoolNumber, + }; + }; + + it('should return the correct log message', () => { + const { exception, sourceSchoolNumber, targetSchoolNumber } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'SCHOOL_MIGRATION_FAILED', + message: 'School could not migrate during user migration process.', + stack: expect.any(String), + data: { + sourceSchoolNumber, + targetSchoolNumber, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.ts new file mode 100644 index 00000000000..93bd64c2ecf --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.ts @@ -0,0 +1,32 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchoolNumberMismatchLoggableException extends BusinessError implements Loggable { + constructor(private readonly sourceSchoolNumber: string, private readonly targetSchoolNumber: string) { + super( + { + type: 'SCHOOL_MIGRATION_FAILED', + title: 'Migration of school failed.', + defaultMessage: 'School could not migrate during user migration process.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + { + sourceSchoolNumber, + targetSchoolNumber, + } + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + sourceSchoolNumber: this.sourceSchoolNumber, + targetSchoolNumber: this.targetSchoolNumber, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..ff7a4d2fc39 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { SchoolNumberMissingLoggableException } from './school-number-missing.loggable-exception'; + +describe(SchoolNumberMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + + const exception = new SchoolNumberMissingLoggableException(schoolId); + + return { + exception, + schoolId, + }; + }; + + it('should return the correct log message', () => { + const { exception, schoolId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'SCHOOL_NUMBER_MISSING', + message: 'The school is missing a official school number.', + stack: expect.any(String), + data: { + schoolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/error/school-number-missing.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/user-login-migration/error/school-number-missing.loggable-exception.ts rename to apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.ts diff --git a/apps/server/src/modules/user-login-migration/error/user-login-migration-already-closed.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.ts similarity index 88% rename from apps/server/src/modules/user-login-migration/error/user-login-migration-already-closed.loggable-exception.ts rename to apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.ts index 321eba624f9..4e5fec1c31b 100644 --- a/apps/server/src/modules/user-login-migration/error/user-login-migration-already-closed.loggable-exception.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.ts @@ -3,7 +3,7 @@ import { EntityId } from '@shared/domain'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; export class UserLoginMigrationAlreadyClosedLoggableException extends UnprocessableEntityException implements Loggable { - constructor(private readonly userLoginMigrationId: EntityId, private readonly closedAt: Date) { + constructor(private readonly closedAt: Date, private readonly userLoginMigrationId?: EntityId) { super(); } diff --git a/apps/server/src/modules/user-login-migration/error/user-login-migration-grace-period-expired-loggable.exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-grace-period-expired-loggable.exception.ts similarity index 100% rename from apps/server/src/modules/user-login-migration/error/user-login-migration-grace-period-expired-loggable.exception.ts rename to apps/server/src/modules/user-login-migration/loggable/user-login-migration-grace-period-expired-loggable.exception.ts diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts new file mode 100644 index 00000000000..a2b1cde606b --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'bson'; +import { UserLoginMigrationMandatoryLoggable } from './user-login-migration-mandatory.loggable'; + +describe(UserLoginMigrationMandatoryLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const userLoginMigrationId = new ObjectId().toHexString(); + const exception = new UserLoginMigrationMandatoryLoggable(userId, userLoginMigrationId, true); + + return { + exception, + userId, + userLoginMigrationId, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId, userLoginMigrationId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + message: 'The school administrator changed the requirement status of the user login migration for his school.', + data: { + userId, + userLoginMigrationId, + mandatory: true, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.ts index 93312a60402..b3bf5724aaf 100644 --- a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.ts @@ -4,7 +4,7 @@ import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from export class UserLoginMigrationMandatoryLoggable implements Loggable { constructor( private readonly userId: EntityId, - private readonly userLoginMigrationId: EntityId, + private readonly userLoginMigrationId: EntityId | undefined, private readonly mandatory: boolean ) {} diff --git a/apps/server/src/modules/user-login-migration/error/user-login-migration-not-found.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-not-found.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/user-login-migration/error/user-login-migration-not-found.loggable-exception.ts rename to apps/server/src/modules/user-login-migration/loggable/user-login-migration-not-found.loggable-exception.ts diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts new file mode 100644 index 00000000000..f15ac763e5c --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts @@ -0,0 +1,32 @@ +import { ObjectId } from 'bson'; +import { UserLoginMigrationStartLoggable } from './user-login-migration-start.loggable'; + +describe(UserLoginMigrationStartLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const userLoginMigrationId = new ObjectId().toHexString(); + const exception = new UserLoginMigrationStartLoggable(userId, userLoginMigrationId); + + return { + exception, + userId, + userLoginMigrationId, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId, userLoginMigrationId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + message: 'The school administrator started the migration for his school.', + data: { + userId, + userLoginMigrationId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.ts index f1ccb50d4d3..150ce3117b1 100644 --- a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.ts @@ -2,7 +2,7 @@ import { EntityId } from '@shared/domain'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; export class UserLoginMigrationStartLoggable implements Loggable { - constructor(private readonly userId: EntityId, private readonly userLoginMigrationId: EntityId) {} + constructor(private readonly userId: EntityId, private readonly userLoginMigrationId: EntityId | undefined) {} getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { diff --git a/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.spec.ts new file mode 100644 index 00000000000..a83d1264ff0 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UserMigrationDatabaseOperationFailedLoggableException } from './user-migration-database-operation-failed.loggable-exception'; + +describe(UserMigrationDatabaseOperationFailedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + const exception = new UserMigrationDatabaseOperationFailedLoggableException(userId, 'migration', new Error()); + + return { + exception, + userId, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_LOGIN_MIGRATION_DATABASE_OPERATION_FAILED', + stack: expect.any(String), + data: { + userId, + operation: 'migration', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.ts new file mode 100644 index 00000000000..39e41b9830a --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.ts @@ -0,0 +1,24 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ErrorUtils } from '@src/core/error/utils'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class UserMigrationDatabaseOperationFailedLoggableException + extends InternalServerErrorException + implements Loggable +{ + constructor(private readonly userId: EntityId, private readonly operation: 'migration' | 'rollback', error: unknown) { + super(ErrorUtils.createHttpExceptionOptions(error)); + } + + public getLogMessage(): ErrorLogMessage { + return { + type: 'USER_LOGIN_MIGRATION_DATABASE_OPERATION_FAILED', + stack: this.stack, + data: { + userId: this.userId, + operation: this.operation, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/mapper/index.ts b/apps/server/src/modules/user-login-migration/mapper/index.ts index 03b0a12e9be..7cf4b79d56c 100644 --- a/apps/server/src/modules/user-login-migration/mapper/index.ts +++ b/apps/server/src/modules/user-login-migration/mapper/index.ts @@ -1,2 +1 @@ -export * from './page-content.mapper'; export * from './user-login-migration.mapper'; diff --git a/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.spec.ts b/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.spec.ts deleted file mode 100644 index e3b1e538c47..00000000000 --- a/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PageContentMapper } from './page-content.mapper'; -import { PageContentDto } from '../service/dto/page-content.dto'; - -describe('PageContentMapper', () => { - let module: TestingModule; - let mapper: PageContentMapper; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [PageContentMapper], - }).compile(); - mapper = module.get(PageContentMapper); - }); - - afterAll(async () => { - await module.close(); - }); - - const setup = () => { - const dto: PageContentDto = { - proceedButtonUrl: 'proceed', - cancelButtonUrl: 'cancel', - }; - return { dto }; - }; - - describe('mapDtoToResponse is called', () => { - describe('when it maps from dto to response', () => { - it('should map the dto to a response', () => { - const { dto } = setup(); - const response = mapper.mapDtoToResponse(dto); - expect(response.proceedButtonUrl).toEqual(dto.proceedButtonUrl); - expect(response.cancelButtonUrl).toEqual(dto.cancelButtonUrl); - }); - }); - }); -}); diff --git a/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.ts b/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.ts deleted file mode 100644 index 7abec9c2208..00000000000 --- a/apps/server/src/modules/user-login-migration/mapper/page-content.mapper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PageContentDto } from '../service/dto/page-content.dto'; -import { PageContentResponse } from '../controller/dto'; - -@Injectable() -export class PageContentMapper { - mapDtoToResponse(dto: PageContentDto): PageContentResponse { - const response: PageContentResponse = new PageContentResponse({ - proceedButtonUrl: dto.proceedButtonUrl, - cancelButtonUrl: dto.cancelButtonUrl, - }); - - return response; - } -} diff --git a/apps/server/src/modules/user-login-migration/mapper/user-login-migration.mapper.ts b/apps/server/src/modules/user-login-migration/mapper/user-login-migration.mapper.ts index 272e2309392..1fecfa7f7aa 100644 --- a/apps/server/src/modules/user-login-migration/mapper/user-login-migration.mapper.ts +++ b/apps/server/src/modules/user-login-migration/mapper/user-login-migration.mapper.ts @@ -7,6 +7,7 @@ export class UserLoginMigrationMapper { const query: UserLoginMigrationQuery = { userId: searchParams.userId, }; + return query; } @@ -20,6 +21,7 @@ export class UserLoginMigrationMapper { finishedAt: domainObject.finishedAt, mandatorySince: domainObject.mandatorySince, }); + return response; } } diff --git a/apps/server/src/modules/user-login-migration/service/dto/index.ts b/apps/server/src/modules/user-login-migration/service/dto/index.ts index 7a92999ded8..bbc5344f4dd 100644 --- a/apps/server/src/modules/user-login-migration/service/dto/index.ts +++ b/apps/server/src/modules/user-login-migration/service/dto/index.ts @@ -1,3 +1,2 @@ export * from './migration.dto'; export * from './page-content.dto'; -export * from './school-migration-flags'; diff --git a/apps/server/src/modules/user-login-migration/service/dto/school-migration-flags.ts b/apps/server/src/modules/user-login-migration/service/dto/school-migration-flags.ts deleted file mode 100644 index b2b0f8b6e52..00000000000 --- a/apps/server/src/modules/user-login-migration/service/dto/school-migration-flags.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SchoolMigrationFlags { - isPossible: boolean; - isMandatory: boolean; -} diff --git a/apps/server/src/modules/user-login-migration/service/migration-check.service.spec.ts b/apps/server/src/modules/user-login-migration/service/migration-check.service.spec.ts index 7afc017cf98..c3e2d1eac91 100644 --- a/apps/server/src/modules/user-login-migration/service/migration-check.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/migration-check.service.spec.ts @@ -44,7 +44,7 @@ describe('MigrationCheckService', () => { await module.close(); }); - describe('shouldUserMigrate is called', () => { + describe('shouldUserMigrate', () => { describe('when no school with the official school number was found', () => { const setup = () => { schoolService.getSchoolBySchoolNumber.mockResolvedValue(null); @@ -87,6 +87,7 @@ describe('MigrationCheckService', () => { targetSystemId: 'targetSystemId', startedAt: new Date('2023-03-03'), }); + schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); userService.findByExternalId.mockResolvedValue(null); userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); diff --git a/apps/server/src/modules/user-login-migration/service/migration-check.service.ts b/apps/server/src/modules/user-login-migration/service/migration-check.service.ts index 70d0ab94066..4c30cc2f8c0 100644 --- a/apps/server/src/modules/user-login-migration/service/migration-check.service.ts +++ b/apps/server/src/modules/user-login-migration/service/migration-check.service.ts @@ -1,8 +1,8 @@ +import { LegacySchoolService } from '@modules/legacy-school'; +import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { EntityId, LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationRepo } from '@shared/repo'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { UserService } from '@modules/user'; @Injectable() export class MigrationCheckService { @@ -12,22 +12,39 @@ export class MigrationCheckService { private readonly userLoginMigrationRepo: UserLoginMigrationRepo ) {} - async shouldUserMigrate(externalUserId: string, systemId: EntityId, officialSchoolNumber: string): Promise { + public async shouldUserMigrate( + externalUserId: string, + systemId: EntityId, + officialSchoolNumber: string + ): Promise { const school: LegacySchoolDo | null = await this.schoolService.getSchoolBySchoolNumber(officialSchoolNumber); - if (school && school.id) { - const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId( - school.id - ); + if (!school?.id) { + return false; + } - const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(school.id); - if (user?.lastLoginSystemChange && userLoginMigration && !userLoginMigration.closedAt) { - const hasMigrated: boolean = user.lastLoginSystemChange > userLoginMigration.startedAt; - return !hasMigrated; - } - return !!userLoginMigration && !userLoginMigration.closedAt; + if (!userLoginMigration || !this.isMigrationActive(userLoginMigration)) { + return false; } - return false; + + const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); + + if (this.isUserMigrated(user, userLoginMigration)) { + return false; + } + + return true; + } + + private isUserMigrated(user: UserDO | null, userLoginMigration: UserLoginMigrationDO): boolean { + return ( + !!user && user.lastLoginSystemChange !== undefined && user.lastLoginSystemChange > userLoginMigration.startedAt + ); + } + + private isMigrationActive(userLoginMigration: UserLoginMigrationDO): boolean { + return !userLoginMigration.closedAt; } } diff --git a/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts index 8addd2afae6..c84339c9fbf 100644 --- a/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts @@ -1,25 +1,26 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { UnprocessableEntityException } from '@nestjs/common'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; -import { ValidationError } from '@shared/common'; import { LegacySchoolDo, Page, UserDO, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationRepo } from '@shared/repo/userloginmigration/user-login-migration.repo'; import { legacySchoolDoFactory, setupEntities, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { ICurrentUser } from '@modules/authentication'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { UserService } from '@modules/user'; -import { OAuthMigrationError } from '@modules/user-login-migration/error/oauth-migration.error'; +import { LegacyLogger, Logger } from '@src/core/logger'; +import { + SchoolMigrationDatabaseOperationFailedLoggableException, + SchoolNumberMismatchLoggableException, +} from '../loggable'; import { SchoolMigrationService } from './school-migration.service'; -describe('SchoolMigrationService', () => { +describe(SchoolMigrationService.name, () => { let module: TestingModule; let service: SchoolMigrationService; let userService: DeepMocked; let schoolService: DeepMocked; let userLoginMigrationRepo: DeepMocked; + let logger: DeepMocked; beforeAll(async () => { jest.useFakeTimers(); @@ -39,6 +40,10 @@ describe('SchoolMigrationService', () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: Logger, + useValue: createMock(), + }, { provide: UserLoginMigrationRepo, useValue: createMock(), @@ -50,6 +55,7 @@ describe('SchoolMigrationService', () => { schoolService = module.get(LegacySchoolService); userService = module.get(UserService); userLoginMigrationRepo = module.get(UserLoginMigrationRepo); + logger = module.get(Logger); await setupEntities(); }); @@ -59,319 +65,234 @@ describe('SchoolMigrationService', () => { await module.close(); }); - describe('validateGracePeriod is called', () => { - describe('when current date is before finish date', () => { + describe('migrateSchool', () => { + describe('when a school without systems successfully migrates', () => { const setup = () => { - jest.setSystemTime(new Date('2023-05-01')); - - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - schoolId: 'schoolId', - targetSystemId: 'systemId', - startedAt: new Date('2023-05-01'), - closedAt: new Date('2023-05-01'), - finishedAt: new Date('2023-05-02'), + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + id: 'schoolId', + name: 'schoolName', + officialSchoolNumber: 'officialSchoolNumber', + externalId: 'firstExternalId', + systems: undefined, }); + const targetSystemId = 'targetSystemId'; + const targetExternalId = 'targetExternalId'; return { - userLoginMigration, + school, + targetSystemId, + sourceExternalId: school.externalId, + targetExternalId, }; }; - it('should not throw', () => { - const { userLoginMigration } = setup(); - - const func = () => service.validateGracePeriod(userLoginMigration); + it('should save the migrated school and add the system', async () => { + const { school, targetSystemId, targetExternalId, sourceExternalId } = setup(); - expect(func).not.toThrow(); - }); - }); + await service.migrateSchool({ ...school }, targetExternalId, targetSystemId); - describe('when current date is after finish date', () => { - const setup = () => { - jest.setSystemTime(new Date('2023-05-03')); - - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - schoolId: 'schoolId', - targetSystemId: 'systemId', - startedAt: new Date('2023-05-01'), - closedAt: new Date('2023-05-01'), - finishedAt: new Date('2023-05-02'), + expect(schoolService.save).toHaveBeenCalledWith({ + ...school, + externalId: targetExternalId, + previousExternalId: sourceExternalId, + systems: [targetSystemId], }); - - return { - userLoginMigration, - }; - }; - - it('should throw validation error', () => { - const { userLoginMigration } = setup(); - - const func = () => service.validateGracePeriod(userLoginMigration); - - expect(func).toThrow( - new ValidationError('grace_period_expired: The grace period after finishing migration has expired') - ); }); }); - }); - describe('schoolToMigrate is called', () => { - describe('when school number is missing', () => { + describe('when a school with systems successfully migrates', () => { const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ id: 'schoolId', name: 'schoolName', officialSchoolNumber: 'officialSchoolNumber', externalId: 'firstExternalId', + systems: ['otherSystemId'], }); - - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); - - const currentUser: ICurrentUser = { - userId: userDO.id, - schoolId: userDO.schoolId, - systemId: 'systemId', - } as ICurrentUser; + const targetSystemId = 'targetSystemId'; + const targetExternalId = 'targetExternalId'; return { - externalId: schoolDO.externalId as string, - currentUser, + school, + targetSystemId, + sourceExternalId: school.externalId, + targetExternalId, }; }; - it('should throw an error', async () => { - const { currentUser, externalId } = setup(); + it('should save the migrated school and add the system', async () => { + const { school, targetSystemId, targetExternalId, sourceExternalId } = setup(); - const func = () => service.schoolToMigrate(currentUser.userId, externalId, undefined); + await service.migrateSchool({ ...school }, targetExternalId, targetSystemId); - await expect(func()).rejects.toThrow( - new OAuthMigrationError( - 'Official school number from target migration system is missing', - 'ext_official_school_number_missing' - ) - ); + expect(schoolService.save).toHaveBeenCalledWith({ + ...school, + externalId: targetExternalId, + previousExternalId: sourceExternalId, + systems: ['otherSystemId', targetSystemId], + }); }); }); - describe('when school could not be found with official school number', () => { + describe('when saving to the database fails', () => { const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ id: 'schoolId', name: 'schoolName', officialSchoolNumber: 'officialSchoolNumber', externalId: 'firstExternalId', }); + const targetSystemId = 'targetSystemId'; + const targetExternalId = 'targetExternalId'; - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); + const error = new Error('Cannot save'); + + schoolService.save.mockRejectedValueOnce(error); + schoolService.save.mockRejectedValueOnce(error); return { - currentUserId: userDO.id as string, - officialSchoolNumber: schoolDO.officialSchoolNumber, - schoolDO, - externalId: schoolDO.externalId as string, - userDO, + school, + targetSystemId, + sourceExternalId: school.externalId, + targetExternalId, + error, }; }; - it('should throw an error', async () => { - const { currentUserId, externalId, officialSchoolNumber, userDO, schoolDO } = setup(); - userService.findById.mockResolvedValue(userDO); - schoolService.getSchoolById.mockResolvedValue(schoolDO); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(null); - - const func = () => service.schoolToMigrate(currentUserId, externalId, officialSchoolNumber); - - await expect(func()).rejects.toThrow( - new OAuthMigrationError( - 'Could not find school by official school number from target migration system', - 'ext_official_school_missing' - ) - ); + it('should roll back any changes to the school', async () => { + const { school, targetSystemId, targetExternalId } = setup(); + + await expect(service.migrateSchool({ ...school }, targetExternalId, targetSystemId)).rejects.toThrow(); + + expect(schoolService.save).toHaveBeenLastCalledWith(school); }); - }); - describe('when current users school not match with school of to migrate user ', () => { - const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ - id: 'schoolId', - name: 'schoolName', - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'firstExternalId', - }); + it('should log a rollback error', async () => { + const { school, targetSystemId, targetExternalId, error } = setup(); - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); + await expect(service.migrateSchool({ ...school }, targetExternalId, targetSystemId)).rejects.toThrow(); - return { - currentUserId: userDO.id as string, - schoolDO, - externalId: schoolDO.externalId as string, - userDO, - }; - }; + expect(logger.warning).toHaveBeenCalledWith( + new SchoolMigrationDatabaseOperationFailedLoggableException(school, 'rollback', error) + ); + }); it('should throw an error', async () => { - const { currentUserId, externalId, schoolDO, userDO } = setup(); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(schoolDO); - schoolDO.officialSchoolNumber = 'OfficialSchoolnumberMismatch'; - schoolService.getSchoolById.mockResolvedValue(schoolDO); - - userService.findById.mockResolvedValue(userDO); - - const func = () => service.schoolToMigrate(currentUserId, externalId, 'targetSchoolNumber'); - - await expect(func()).rejects.toThrow( - new OAuthMigrationError( - 'Current users school is not the same as school found by official school number from target migration system', - 'ext_official_school_number_mismatch', - 'targetSchoolNumber', - schoolDO.officialSchoolNumber - ) + const { school, targetSystemId, targetExternalId, error } = setup(); + + await expect(service.migrateSchool({ ...school }, targetExternalId, targetSystemId)).rejects.toThrow( + new SchoolMigrationDatabaseOperationFailedLoggableException(school, 'migration', error) ); }); }); + }); - describe('when school was already migrated', () => { + describe('getSchoolForMigration', () => { + describe('when the school has to be migrated', () => { const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + const officialSchoolNumber = 'officialSchoolNumber'; + const sourceExternalId = 'sourceExternalId'; + const targetExternalId = 'targetExternalId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ id: 'schoolId', name: 'schoolName', - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'firstExternalId', + officialSchoolNumber, + externalId: sourceExternalId, }); - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); + const user: UserDO = userDoFactory.build({ id: new ObjectId().toHexString(), schoolId: school.id }); + + userService.findById.mockResolvedValue(user); + schoolService.getSchoolById.mockResolvedValue(school); return { - currentUserId: userDO.id as string, - schoolDO, - externalId: schoolDO.externalId as string, - userDO, + userId: user.id as string, + user, + officialSchoolNumber, + school, + targetExternalId, }; }; - it('should return null ', async () => { - const { currentUserId, externalId, schoolDO, userDO } = setup(); - userService.findById.mockResolvedValue(userDO); - schoolService.getSchoolById.mockResolvedValue(schoolDO); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(schoolDO); + it('should return the school', async () => { + const { userId, targetExternalId, officialSchoolNumber, school } = setup(); - const result: LegacySchoolDo | null = await service.schoolToMigrate( - currentUserId, - externalId, - schoolDO.officialSchoolNumber - ); + const result = await service.getSchoolForMigration(userId, targetExternalId, officialSchoolNumber); - expect(result).toBeNull(); + expect(result).toEqual(school); }); }); - describe('when school has to be migrated', () => { + describe('when the school is already migrated', () => { const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + const officialSchoolNumber = 'officialSchoolNumber'; + const sourceExternalId = 'sourceExternalId'; + const targetExternalId = 'targetExternalId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ id: 'schoolId', name: 'schoolName', - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'firstExternalId', + officialSchoolNumber, + externalId: targetExternalId, + previousExternalId: sourceExternalId, }); - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); + const user: UserDO = userDoFactory.build({ id: new ObjectId().toHexString(), schoolId: school.id }); + + userService.findById.mockResolvedValue(user); + schoolService.getSchoolById.mockResolvedValue(school); return { - currentUserId: userDO.id as string, - schoolDO, - userDO, + userId: user.id as string, + user, + officialSchoolNumber, + school, + targetExternalId, }; }; - it('should return migrated school', async () => { - const { currentUserId, schoolDO, userDO } = setup(); - schoolService.getSchoolById.mockResolvedValue(schoolDO); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(schoolDO); - userService.findById.mockResolvedValue(userDO); + it('should return null', async () => { + const { userId, targetExternalId, officialSchoolNumber } = setup(); - const result: LegacySchoolDo | null = await service.schoolToMigrate( - currentUserId, - 'newExternalId', - schoolDO.officialSchoolNumber - ); + const result = await service.getSchoolForMigration(userId, targetExternalId, officialSchoolNumber); - expect(result).toEqual(schoolDO); + expect(result).toBeNull(); }); }); - }); - describe('migrateSchool is called', () => { - describe('when school will be migrated', () => { + describe('when the school number from the external system is not the same as the school number of the users school', () => { const setup = () => { - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + const officialSchoolNumber = 'officialSchoolNumber'; + const otherOfficialSchoolNumber = 'notTheSameOfficialSchoolNumber'; + const sourceExternalId = 'sourceExternalId'; + const targetExternalId = 'targetExternalId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ id: 'schoolId', name: 'schoolName', - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'firstExternalId', + officialSchoolNumber, + externalId: sourceExternalId, }); - const targetSystemId = 'targetSystemId'; + + const user: UserDO = userDoFactory.build({ id: new ObjectId().toHexString(), schoolId: school.id }); + + userService.findById.mockResolvedValue(user); + schoolService.getSchoolById.mockResolvedValue(school); return { - schoolDO, - targetSystemId, - firstExternalId: schoolDO.externalId, + userId: user.id as string, + user, + officialSchoolNumber, + otherOfficialSchoolNumber, + school, + targetExternalId, }; }; - it('should save the migrated school', async () => { - const { schoolDO, targetSystemId, firstExternalId } = setup(); - const newExternalId = 'newExternalId'; + it('should throw a school number mismatch error', async () => { + const { userId, targetExternalId, officialSchoolNumber, otherOfficialSchoolNumber } = setup(); - await service.migrateSchool(newExternalId, schoolDO, targetSystemId); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - systems: [targetSystemId], - previousExternalId: firstExternalId, - externalId: newExternalId, - }) - ); - }); - - describe('when there are other systems before', () => { - it('should add the system to migrated school', async () => { - const { schoolDO, targetSystemId } = setup(); - schoolDO.systems = ['existingSystem']; - - await service.migrateSchool('newExternalId', schoolDO, targetSystemId); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - systems: ['existingSystem', targetSystemId], - }) - ); - }); - }); - - describe('when there are no systems in School', () => { - it('should add the system to migrated school', async () => { - const { schoolDO, targetSystemId } = setup(); - schoolDO.systems = undefined; - - await service.migrateSchool('newExternalId', schoolDO, targetSystemId); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - systems: [targetSystemId], - }) - ); - }); - }); - - describe('when an error occurred', () => { - it('should save the old schoolDo (rollback the migration)', async () => { - const { schoolDO, targetSystemId } = setup(); - schoolService.save.mockRejectedValueOnce(new Error()); - - await service.migrateSchool('newExternalId', schoolDO, targetSystemId); - - expect(schoolService.save).toHaveBeenCalledWith(schoolDO); - }); + await expect( + service.getSchoolForMigration(userId, targetExternalId, otherOfficialSchoolNumber) + ).rejects.toThrow(new SchoolNumberMismatchLoggableException(officialSchoolNumber, otherOfficialSchoolNumber)); }); }); }); @@ -382,8 +303,8 @@ describe('SchoolMigrationService', () => { const closedAt: Date = new Date('2023-05-01'); const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - schoolId: 'schoolId', - targetSystemId: 'targetSystemId', + schoolId: new ObjectId().toHexString(), + targetSystemId: new ObjectId().toHexString(), startedAt: new Date('2023-05-01'), closedAt, finishedAt: new Date('2023-05-01'), @@ -391,18 +312,18 @@ describe('SchoolMigrationService', () => { const users: UserDO[] = userDoFactory.buildListWithId(3, { outdatedSince: undefined }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); userService.findUsers.mockResolvedValue(new Page(users, users.length)); return { closedAt, + userLoginMigration, }; }; it('should save migrated user with removed outdatedSince entry', async () => { - const { closedAt } = setup(); + const { closedAt, userLoginMigration } = setup(); - await service.markUnmigratedUsersAsOutdated('schoolId'); + await service.markUnmigratedUsersAsOutdated(userLoginMigration); expect(userService.saveAll).toHaveBeenCalledWith([ expect.objectContaining>({ outdatedSince: closedAt }), @@ -411,20 +332,6 @@ describe('SchoolMigrationService', () => { ]); }); }); - - describe('when the school has no migration', () => { - const setup = () => { - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - }; - - it('should throw an UnprocessableEntityException', async () => { - setup(); - - const func = async () => service.markUnmigratedUsersAsOutdated('schoolId'); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); }); describe('unmarkOutdatedUsers', () => { @@ -440,14 +347,17 @@ describe('SchoolMigrationService', () => { const users: UserDO[] = userDoFactory.buildListWithId(3, { outdatedSince: new Date('2023-05-02') }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); userService.findUsers.mockResolvedValue(new Page(users, users.length)); + + return { + userLoginMigration, + }; }; it('should save migrated user with removed outdatedSince entry', async () => { - setup(); + const { userLoginMigration } = setup(); - await service.unmarkOutdatedUsers('schoolId'); + await service.unmarkOutdatedUsers(userLoginMigration); expect(userService.saveAll).toHaveBeenCalledWith([ expect.objectContaining>({ outdatedSince: undefined }), @@ -456,20 +366,6 @@ describe('SchoolMigrationService', () => { ]); }); }); - - describe('when the school has no migration', () => { - const setup = () => { - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - }; - - it('should throw an UnprocessableEntityException', async () => { - setup(); - - const func = async () => service.unmarkOutdatedUsers('schoolId'); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); }); describe('hasSchoolMigratedUser', () => { @@ -483,7 +379,7 @@ describe('SchoolMigrationService', () => { }; }; - it('should call userLoginMigrationRepo.findBySchoolId', async () => { + it('should find user login migration by school id', async () => { setup(); await service.hasSchoolMigratedUser('schoolId'); @@ -503,7 +399,7 @@ describe('SchoolMigrationService', () => { }; }; - it('should call userService.findUsers', async () => { + it('should call user service to find users', async () => { const { userLoginMigration } = setup(); await service.hasSchoolMigratedUser('schoolId'); diff --git a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts index 147d9ec112b..41cabc13d03 100644 --- a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts @@ -1,94 +1,103 @@ -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { ValidationError } from '@shared/common'; -import { Page, LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain'; -import { UserLoginMigrationRepo } from '@shared/repo'; -import { LegacyLogger } from '@src/core/logger'; import { LegacySchoolService } from '@modules/legacy-school'; import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { LegacySchoolDo, Page, UserDO, UserLoginMigrationDO } from '@shared/domain'; +import { UserLoginMigrationRepo } from '@shared/repo'; +import { LegacyLogger, Logger } from '@src/core/logger'; import { performance } from 'perf_hooks'; -import { OAuthMigrationError } from '../error'; +import { + SchoolMigrationDatabaseOperationFailedLoggableException, + SchoolNumberMismatchLoggableException, +} from '../loggable'; @Injectable() export class SchoolMigrationService { constructor( private readonly schoolService: LegacySchoolService, - private readonly logger: LegacyLogger, + private readonly legacyLogger: LegacyLogger, + private readonly logger: Logger, private readonly userService: UserService, private readonly userLoginMigrationRepo: UserLoginMigrationRepo ) {} - validateGracePeriod(userLoginMigration: UserLoginMigrationDO) { - if (userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime()) { - throw new ValidationError('grace_period_expired: The grace period after finishing migration has expired', { - finishedAt: userLoginMigration.finishedAt, - }); - } - } - - async migrateSchool(externalId: string, existingSchool: LegacySchoolDo, targetSystemId: string): Promise { + public async migrateSchool( + existingSchool: LegacySchoolDo, + externalId: string, + targetSystemId: string + ): Promise { const schoolDOCopy: LegacySchoolDo = new LegacySchoolDo({ ...existingSchool }); try { await this.doMigration(externalId, existingSchool, targetSystemId); - } catch (e: unknown) { - await this.rollbackMigration(schoolDOCopy); - this.logger.log({ - message: `This error occurred during migration of School with official school number`, - officialSchoolNumber: existingSchool.officialSchoolNumber, - error: e, - }); + } catch (error: unknown) { + await this.tryRollbackMigration(schoolDOCopy); + + throw new SchoolMigrationDatabaseOperationFailedLoggableException(existingSchool, 'migration', error); } } - async schoolToMigrate( - currentUserId: string, - externalId: string, - officialSchoolNumber: string | undefined - ): Promise { - if (!officialSchoolNumber) { - throw new OAuthMigrationError( - 'Official school number from target migration system is missing', - 'ext_official_school_number_missing' - ); + private async doMigration(externalId: string, school: LegacySchoolDo, targetSystemId: string): Promise { + school.previousExternalId = school.externalId; + school.externalId = externalId; + if (!school.systems) { + school.systems = []; } - - const userDO: UserDO | null = await this.userService.findById(currentUserId); - if (userDO) { - const schoolDO: LegacySchoolDo = await this.schoolService.getSchoolById(userDO.schoolId); - this.checkOfficialSchoolNumbersMatch(schoolDO, officialSchoolNumber); + if (!school.systems.includes(targetSystemId)) { + school.systems.push(targetSystemId); } - const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolBySchoolNumber( - officialSchoolNumber - ); + await this.schoolService.save(school); + } - if (!existingSchool) { - throw new OAuthMigrationError( - 'Could not find school by official school number from target migration system', - 'ext_official_school_missing' + private async tryRollbackMigration(originalSchoolDO: LegacySchoolDo): Promise { + try { + await this.schoolService.save(originalSchoolDO); + } catch (error: unknown) { + this.logger.warning( + new SchoolMigrationDatabaseOperationFailedLoggableException(originalSchoolDO, 'rollback', error) ); } + } + + public async getSchoolForMigration( + userId: string, + externalId: string, + officialSchoolNumber: string + ): Promise { + const user: UserDO = await this.userService.findById(userId); + const school: LegacySchoolDo = await this.schoolService.getSchoolById(user.schoolId); - const schoolMigrated: boolean = this.hasSchoolMigrated(externalId, existingSchool.externalId); + this.checkOfficialSchoolNumbersMatch(school, officialSchoolNumber); + + const schoolMigrated: boolean = this.hasSchoolMigrated(school.externalId, externalId); if (schoolMigrated) { return null; } - return existingSchool; + return school; } - async markUnmigratedUsersAsOutdated(schoolId: string): Promise { - const startTime: number = performance.now(); + private checkOfficialSchoolNumbersMatch(schoolDO: LegacySchoolDo, officialExternalSchoolNumber: string): void { + if (schoolDO.officialSchoolNumber !== officialExternalSchoolNumber) { + throw new SchoolNumberMismatchLoggableException( + schoolDO.officialSchoolNumber ?? '', + officialExternalSchoolNumber + ); + } + } - const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); + private hasSchoolMigrated(sourceExternalId: string | undefined, targetExternalId: string): boolean { + const isExternalIdEquivalent: boolean = sourceExternalId === targetExternalId; - if (!userLoginMigration) { - throw new UnprocessableEntityException(`School ${schoolId} has no UserLoginMigration`); - } + return isExternalIdEquivalent; + } + + public async markUnmigratedUsersAsOutdated(userLoginMigration: UserLoginMigrationDO): Promise { + const startTime: number = performance.now(); const notMigratedUsers: Page = await this.userService.findUsers({ - schoolId, + schoolId: userLoginMigration.schoolId, isOutdated: false, lastLoginSystemChangeSmallerThan: userLoginMigration.startedAt, }); @@ -100,20 +109,18 @@ export class SchoolMigrationService { await this.userService.saveAll(notMigratedUsers.data); const endTime: number = performance.now(); - this.logger.warn(`completeMigration for schoolId ${schoolId} took ${endTime - startTime} milliseconds`); + this.legacyLogger.warn( + `markUnmigratedUsersAsOutdated for schoolId ${userLoginMigration.schoolId} took ${ + endTime - startTime + } milliseconds` + ); } - async unmarkOutdatedUsers(schoolId: string): Promise { + public async unmarkOutdatedUsers(userLoginMigration: UserLoginMigrationDO): Promise { const startTime: number = performance.now(); - const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); - - if (!userLoginMigration) { - throw new UnprocessableEntityException(`School ${schoolId} has no UserLoginMigration`); - } - const migratedUsers: Page = await this.userService.findUsers({ - schoolId, + schoolId: userLoginMigration.schoolId, outdatedSince: userLoginMigration.finishedAt, }); @@ -124,45 +131,12 @@ export class SchoolMigrationService { await this.userService.saveAll(migratedUsers.data); const endTime: number = performance.now(); - this.logger.warn(`restartMigration for schoolId ${schoolId} took ${endTime - startTime} milliseconds`); - } - - private async doMigration(externalId: string, schoolDO: LegacySchoolDo, targetSystemId: string): Promise { - if (schoolDO.systems) { - schoolDO.systems.push(targetSystemId); - } else { - schoolDO.systems = [targetSystemId]; - } - schoolDO.previousExternalId = schoolDO.externalId; - schoolDO.externalId = externalId; - await this.schoolService.save(schoolDO); - } - - private async rollbackMigration(originalSchoolDO: LegacySchoolDo) { - if (originalSchoolDO) { - await this.schoolService.save(originalSchoolDO); - } - } - - private checkOfficialSchoolNumbersMatch(schoolDO: LegacySchoolDo, officialExternalSchoolNumber: string): void { - if (schoolDO.officialSchoolNumber !== officialExternalSchoolNumber) { - throw new OAuthMigrationError( - 'Current users school is not the same as school found by official school number from target migration system', - 'ext_official_school_number_mismatch', - schoolDO.officialSchoolNumber, - officialExternalSchoolNumber - ); - } - } - - private hasSchoolMigrated(sourceExternalId: string, targetExternalId?: string): boolean { - if (sourceExternalId === targetExternalId) { - return true; - } - return false; + this.legacyLogger.warn( + `unmarkOutdatedUsers for schoolId ${userLoginMigration.schoolId} took ${endTime - startTime} milliseconds` + ); } - async hasSchoolMigratedUser(schoolId: string): Promise { + public async hasSchoolMigratedUser(schoolId: string): Promise { const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); if (!userLoginMigration) { 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 01e12e0df19..0edaf9d1a38 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 @@ -1,20 +1,22 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; -import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityId, LegacySchoolDo, SchoolFeatures, UserDO, UserLoginMigrationDO } from '@shared/domain'; -import { UserLoginMigrationRepo } from '@shared/repo'; -import { legacySchoolDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { LegacySchoolService } from '@modules/legacy-school'; import { SystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; -import { UserLoginMigrationNotFoundLoggableException } from '../error'; -import { SchoolMigrationService } from './school-migration.service'; +import { InternalServerErrorException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId, LegacySchoolDo, SchoolFeatures, UserDO, UserLoginMigrationDO } from '@shared/domain'; +import { UserLoginMigrationRepo } from '@shared/repo'; +import { legacySchoolDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { + UserLoginMigrationAlreadyClosedLoggableException, + UserLoginMigrationGracePeriodExpiredLoggableException, +} from '../loggable'; import { UserLoginMigrationService } from './user-login-migration.service'; -describe('UserLoginMigrationService', () => { +describe(UserLoginMigrationService.name, () => { let module: TestingModule; let service: UserLoginMigrationService; @@ -22,7 +24,6 @@ describe('UserLoginMigrationService', () => { let schoolService: DeepMocked; let systemService: DeepMocked; let userLoginMigrationRepo: DeepMocked; - let schoolMigrationService: DeepMocked; const mockedDate: Date = new Date('2023-05-02'); const finishDate: Date = new Date( @@ -52,10 +53,6 @@ describe('UserLoginMigrationService', () => { provide: UserLoginMigrationRepo, useValue: createMock(), }, - { - provide: SchoolMigrationService, - useValue: createMock(), - }, ], }).compile(); @@ -64,7 +61,6 @@ describe('UserLoginMigrationService', () => { schoolService = module.get(LegacySchoolService); systemService = module.get(SystemService); userLoginMigrationRepo = module.get(UserLoginMigrationRepo); - schoolMigrationService = module.get(SchoolMigrationService); }); afterAll(async () => { @@ -160,404 +156,6 @@ describe('UserLoginMigrationService', () => { }); }); - describe('setMigration', () => { - describe('when first starting the migration', () => { - describe('when the school has no systems', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - targetSystemId, - }; - }; - - it('should save the UserLoginMigration with start date and target system', async () => { - const { schoolId, targetSystemId } = setup(); - const expected: UserLoginMigrationDO = new UserLoginMigrationDO({ - id: new ObjectId().toHexString(), - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, true); - - expect(result).toEqual(expected); - }); - }); - - describe('when the school has systems', () => { - const setup = () => { - const sourceSystemId: EntityId = new ObjectId().toHexString(); - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [sourceSystemId] }, schoolId); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - targetSystemId, - sourceSystemId, - }; - }; - - it('should save the UserLoginMigration with start date, target system and source system', async () => { - const { schoolId, targetSystemId, sourceSystemId } = setup(); - const expected: UserLoginMigrationDO = new UserLoginMigrationDO({ - id: new ObjectId().toHexString(), - sourceSystemId, - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, true); - - expect(result).toEqual(expected); - }); - }); - - describe('when the school has a feature', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - targetSystemId, - }; - }; - - it('should add the OAUTH_PROVISIONING_ENABLED feature to the schools feature list', async () => { - const { schoolId, school } = setup(); - const existingFeature: SchoolFeatures = 'otherFeature' as SchoolFeatures; - school.features = [existingFeature]; - - await service.setMigration(schoolId, true, undefined, undefined); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - features: [existingFeature, SchoolFeatures.OAUTH_PROVISIONING_ENABLED], - }) - ); - }); - }); - - describe('when the school has no features yet', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: undefined }, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - targetSystemId, - }; - }; - - it('should set the OAUTH_PROVISIONING_ENABLED feature for the school', async () => { - const { schoolId } = setup(); - - await service.setMigration(schoolId, true, undefined, undefined); - - expect(schoolService.save).toHaveBeenCalledWith( - expect.objectContaining>({ - features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], - }) - ); - }); - }); - - describe('when modifying a migration that does not exist on the school', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - schoolService.getSchoolById.mockResolvedValue(school); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - }; - }; - - it('should throw an UnprocessableEntityException', async () => { - const { schoolId } = setup(); - - const func = async () => service.setMigration(schoolId, undefined, true, true); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); - - describe('when creating a new migration but the SANIS system does not exist', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); - - return { - schoolId, - school, - }; - }; - - it('should throw an InternalServerErrorException', async () => { - const { schoolId } = setup(); - - const func = async () => service.setMigration(schoolId, true); - - await expect(func).rejects.toThrow(InternalServerErrorException); - }); - }); - }); - - describe('when restarting the migration', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - const userLoginMigrationId: EntityId = new ObjectId().toHexString(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - id: userLoginMigrationId, - targetSystemId, - schoolId, - startedAt: mockedDate, - closedAt: mockedDate, - finishedAt: finishDate, - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); - - return { - schoolId, - userLoginMigration, - }; - }; - - it('should save the UserLoginMigration without close date and finish date', async () => { - const { schoolId, userLoginMigration } = setup(); - const expected: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - ...userLoginMigration, - closedAt: undefined, - finishedAt: undefined, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, true, undefined, false); - - expect(result).toEqual(expected); - }); - }); - - describe('when setting the migration to mandatory', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - const userLoginMigrationId: EntityId = new ObjectId().toHexString(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - id: userLoginMigrationId, - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); - - return { - schoolId, - userLoginMigration, - }; - }; - - it('should save the UserLoginMigration with mandatory date', async () => { - const { schoolId, userLoginMigration } = setup(); - const expected: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - ...userLoginMigration, - mandatorySince: mockedDate, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, undefined, true); - - expect(result).toEqual(expected); - }); - }); - - describe('when setting the migration back to optional', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - const userLoginMigrationId: EntityId = new ObjectId().toHexString(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - id: userLoginMigrationId, - targetSystemId, - schoolId, - startedAt: mockedDate, - mandatorySince: mockedDate, - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); - - return { - schoolId, - userLoginMigration, - }; - }; - - it('should save the UserLoginMigration without mandatory date', async () => { - const { schoolId, userLoginMigration } = setup(); - const expected: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - ...userLoginMigration, - mandatorySince: undefined, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, undefined, false); - - expect(result).toEqual(expected); - }); - }); - - describe('when closing the migration', () => { - const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(undefined, schoolId); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - const system: SystemDto = new SystemDto({ - id: targetSystemId, - type: 'oauth2', - alias: 'SANIS', - }); - - const userLoginMigrationId: EntityId = new ObjectId().toHexString(); - const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - id: userLoginMigrationId, - targetSystemId, - schoolId, - startedAt: mockedDate, - }); - - schoolService.getSchoolById.mockResolvedValue(school); - systemService.findByType.mockResolvedValue([system]); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); - - return { - schoolId, - userLoginMigration, - }; - }; - - it('should call schoolService.removeFeature', async () => { - const { schoolId } = setup(); - - await service.setMigration(schoolId, undefined, undefined, true); - - expect(schoolService.removeFeature).toHaveBeenCalledWith( - schoolId, - SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION - ); - }); - - it('should save the UserLoginMigration with close date and finish date', async () => { - const { schoolId, userLoginMigration } = setup(); - const expected: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - ...userLoginMigration, - closedAt: mockedDate, - finishedAt: finishDate, - }); - userLoginMigrationRepo.save.mockResolvedValue(expected); - - const result: UserLoginMigrationDO = await service.setMigration(schoolId, undefined, undefined, true); - - expect(result).toEqual(expected); - }); - }); - }); - describe('startMigration', () => { describe('when schoolId is given', () => { const setup = () => { @@ -816,7 +414,7 @@ describe('UserLoginMigrationService', () => { it('should call userLoginMigrationRepo.delete', async () => { const { userLoginMigration } = setup(); - await service.deleteUserLoginMigration(userLoginMigration); + await service.deleteUserLoginMigration({ ...userLoginMigration }); expect(userLoginMigrationRepo.delete).toHaveBeenCalledWith(userLoginMigration); }); @@ -824,73 +422,100 @@ describe('UserLoginMigrationService', () => { }); describe('restartMigration', () => { - describe('when migration restart was successfully', () => { + describe('when the migration can be restarted', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ startedAt: mockedDate, + closedAt: mockedDate, + finishedAt: finishDate, }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigrationDO); - schoolMigrationService.unmarkOutdatedUsers.mockResolvedValue(); - userLoginMigrationRepo.save.mockResolvedValue(userLoginMigrationDO); + const restartedUserLoginMigration: UserLoginMigrationDO = new UserLoginMigrationDO({ + ...userLoginMigration, + closedAt: undefined, + finishedAt: undefined, + }); + + userLoginMigrationRepo.save.mockResolvedValueOnce(restartedUserLoginMigration); return { - schoolId, - targetSystemId, - userLoginMigrationDO, + userLoginMigration, + restartedUserLoginMigration, }; }; - it('should call userLoginMigrationRepo', async () => { - const { schoolId, userLoginMigrationDO } = setup(); + it('should save the migration without closedAt and finishedAt timestamps', async () => { + const { userLoginMigration, restartedUserLoginMigration } = setup(); - await service.restartMigration(schoolId); + await service.restartMigration({ ...userLoginMigration }); - expect(userLoginMigrationRepo.findBySchoolId).toHaveBeenCalledWith(schoolId); - expect(userLoginMigrationRepo.save).toHaveBeenCalledWith(userLoginMigrationDO); + expect(userLoginMigrationRepo.save).toHaveBeenCalledWith(restartedUserLoginMigration); }); - it('should call schoolMigrationService', async () => { - const { schoolId } = setup(); + it('should return the migration', async () => { + const { userLoginMigration, restartedUserLoginMigration } = setup(); - await service.restartMigration(schoolId); + const result: UserLoginMigrationDO = await service.restartMigration({ ...userLoginMigration }); - expect(schoolMigrationService.unmarkOutdatedUsers).toHaveBeenCalledWith(schoolId); + expect(result).toEqual(restartedUserLoginMigration); }); }); - describe('when migration could not be found', () => { + describe('when the migration is still running', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ startedAt: mockedDate, }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); + return { + userLoginMigration, + }; + }; + + it('should not save the migration again', async () => { + const { userLoginMigration } = setup(); + + await service.restartMigration({ ...userLoginMigration }); + + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return the migration', async () => { + const { userLoginMigration } = setup(); + + const result: UserLoginMigrationDO = await service.restartMigration({ ...userLoginMigration }); + + expect(result).toEqual(userLoginMigration); + }); + }); + + describe('when the grace period for the user login migration is expired', () => { + const setup = () => { + const dateInThePast: Date = new Date(mockedDate.getTime() - 100); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + closedAt: dateInThePast, + finishedAt: dateInThePast, + }); return { - schoolId, - targetSystemId, - userLoginMigrationDO, + userLoginMigration, + dateInThePast, }; }; - it('should throw UserLoginMigrationLoggableException ', async () => { - const { schoolId } = setup(); + it('should not save the user login migration again', async () => { + const { userLoginMigration } = setup(); + + await expect(service.restartMigration({ ...userLoginMigration })).rejects.toThrow(); + + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); - const func = async () => service.restartMigration(schoolId); + it('should return throw an error', async () => { + const { userLoginMigration, dateInThePast } = setup(); - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + await expect(service.restartMigration({ ...userLoginMigration })).rejects.toThrow( + new UserLoginMigrationGracePeriodExpiredLoggableException(userLoginMigration.id as string, dateInThePast) + ); }); }); }); @@ -898,34 +523,25 @@ describe('UserLoginMigrationService', () => { describe('setMigrationMandatory', () => { describe('when migration is set to mandatory', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ startedAt: mockedDate, mandatorySince: undefined, }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigrationDO); - userLoginMigrationRepo.save.mockResolvedValue(userLoginMigrationDO); + userLoginMigrationRepo.save.mockResolvedValue(userLoginMigration); return { - schoolId, - targetSystemId, - userLoginMigrationDO, + userLoginMigration, }; }; it('should call save the user login migration', async () => { - const { schoolId, userLoginMigrationDO } = setup(); + const { userLoginMigration } = setup(); - await service.setMigrationMandatory(schoolId, true); + await service.setMigrationMandatory(userLoginMigration, true); expect(userLoginMigrationRepo.save).toHaveBeenCalledWith({ - ...userLoginMigrationDO, + ...userLoginMigration, mandatorySince: mockedDate, }); }); @@ -933,65 +549,76 @@ describe('UserLoginMigrationService', () => { describe('when migration is set to optional', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); - - const targetSystemId: EntityId = new ObjectId().toHexString(); - - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ startedAt: mockedDate, mandatorySince: mockedDate, }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigrationDO); - userLoginMigrationRepo.save.mockResolvedValue(userLoginMigrationDO); - return { - schoolId, - targetSystemId, - userLoginMigrationDO, + userLoginMigration, }; }; it('should call save the user login migration', async () => { - const { schoolId, userLoginMigrationDO } = setup(); + const { userLoginMigration } = setup(); - await service.setMigrationMandatory(schoolId, false); + await service.setMigrationMandatory(userLoginMigration, false); expect(userLoginMigrationRepo.save).toHaveBeenCalledWith({ - ...userLoginMigrationDO, + ...userLoginMigration, mandatorySince: undefined, }); }); }); - describe('when migration could not be found', () => { + describe('when the grace period for the user login migration is expired', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); + const dateInThePast: Date = new Date(mockedDate.getTime() - 100); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + closedAt: dateInThePast, + finishedAt: dateInThePast, + }); - const targetSystemId: EntityId = new ObjectId().toHexString(); + return { + userLoginMigration, + dateInThePast, + }; + }; - const userLoginMigrationDO: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - targetSystemId, - schoolId, - startedAt: mockedDate, + it('should not save the user login migration again', async () => { + const { userLoginMigration } = setup(); + + await expect(service.setMigrationMandatory({ ...userLoginMigration }, true)).rejects.toThrow(); + + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return throw an error', async () => { + const { userLoginMigration, dateInThePast } = setup(); + + await expect(service.setMigrationMandatory({ ...userLoginMigration }, true)).rejects.toThrow( + new UserLoginMigrationGracePeriodExpiredLoggableException(userLoginMigration.id as string, dateInThePast) + ); + }); + }); + + describe('when migration is closed', () => { + const setup = () => { + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + closedAt: new Date(2023, 5), }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); return { - schoolId, - targetSystemId, - userLoginMigrationDO, + userLoginMigration, }; }; - it('should throw UserLoginMigrationLoggableException ', async () => { - const { schoolId } = setup(); + it('should throw a UserLoginMigrationAlreadyClosedLoggableException', async () => { + const { userLoginMigration } = setup(); - const func = async () => service.setMigrationMandatory(schoolId, true); + const func = async () => service.setMigrationMandatory(userLoginMigration, true); - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + await expect(func).rejects.toThrow(UserLoginMigrationAlreadyClosedLoggableException); }); }); }); @@ -999,7 +626,6 @@ describe('UserLoginMigrationService', () => { describe('closeMigration', () => { describe('when a migration can be closed', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); const userLoginMigration = userLoginMigrationDOFactory.buildWithId(); const closedUserLoginMigration = new UserLoginMigrationDO({ ...userLoginMigration, @@ -1007,60 +633,99 @@ describe('UserLoginMigrationService', () => { finishedAt: finishDate, }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(userLoginMigration); userLoginMigrationRepo.save.mockResolvedValue(closedUserLoginMigration); return { - schoolId, + userLoginMigration, closedUserLoginMigration, }; }; - it('should call schoolService.removeFeature', async () => { - const { schoolId } = setup(); + it('should remove the "ldap sync during migration" school feature', async () => { + const { userLoginMigration } = setup(); - await service.closeMigration(schoolId); + await service.closeMigration({ ...userLoginMigration }); expect(schoolService.removeFeature).toHaveBeenCalledWith( - schoolId, + userLoginMigration.schoolId, SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION ); }); it('should save the closed user login migration', async () => { - const { schoolId, closedUserLoginMigration } = setup(); + const { userLoginMigration, closedUserLoginMigration } = setup(); - await service.closeMigration(schoolId); + await service.closeMigration({ ...userLoginMigration }); expect(userLoginMigrationRepo.save).toHaveBeenCalledWith(closedUserLoginMigration); }); it('should return the closed user login migration', async () => { - const { schoolId, closedUserLoginMigration } = setup(); + const { userLoginMigration, closedUserLoginMigration } = setup(); - const result = await service.closeMigration(schoolId); + const result: UserLoginMigrationDO = await service.closeMigration({ ...userLoginMigration }); expect(result).toEqual(closedUserLoginMigration); }); }); - describe('when a migration can be closed', () => { + describe('when the user login migration was already closed', () => { const setup = () => { - const schoolId: EntityId = new ObjectId().toHexString(); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + closedAt: mockedDate, + finishedAt: finishDate, + }); - userLoginMigrationRepo.findBySchoolId.mockResolvedValue(null); + return { + userLoginMigration, + }; + }; + + it('should not save the user login migration again', async () => { + const { userLoginMigration } = setup(); + + await service.closeMigration({ ...userLoginMigration }); + + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return the already closed user login migration', async () => { + const { userLoginMigration } = setup(); + + const result: UserLoginMigrationDO = await service.closeMigration({ ...userLoginMigration }); + + expect(result).toEqual(userLoginMigration); + }); + }); + + describe('when the grace period for the user login migration is expired', () => { + const setup = () => { + const dateInThePast: Date = new Date(mockedDate.getTime() - 100); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + closedAt: dateInThePast, + finishedAt: dateInThePast, + }); return { - schoolId, + userLoginMigration, + dateInThePast, }; }; - it('should save the closed user login migration', async () => { - const { schoolId } = setup(); + it('should not save the user login migration again', async () => { + const { userLoginMigration } = setup(); + + await expect(service.closeMigration({ ...userLoginMigration })).rejects.toThrow(); - const func = () => service.closeMigration(schoolId); + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return throw an error', async () => { + const { userLoginMigration, dateInThePast } = setup(); - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + await expect(service.closeMigration({ ...userLoginMigration })).rejects.toThrow( + new UserLoginMigrationGracePeriodExpiredLoggableException(userLoginMigration.id as string, dateInThePast) + ); }); }); }); 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 534bb71e104..459b163f119 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,12 +1,14 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, LegacySchoolDo, SchoolFeatures, SystemTypeEnum, UserDO, UserLoginMigrationDO } from '@shared/domain'; -import { UserLoginMigrationRepo } from '@shared/repo'; import { LegacySchoolService } from '@modules/legacy-school'; import { SystemDto, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; -import { UserLoginMigrationNotFoundLoggableException } from '../error'; -import { SchoolMigrationService } from './school-migration.service'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, SchoolFeatures, SystemTypeEnum, UserDO, UserLoginMigrationDO } from '@shared/domain'; +import { UserLoginMigrationRepo } from '@shared/repo'; +import { + UserLoginMigrationAlreadyClosedLoggableException, + UserLoginMigrationGracePeriodExpiredLoggableException, +} from '../loggable'; @Injectable() export class UserLoginMigrationService { @@ -14,72 +16,10 @@ export class UserLoginMigrationService { private readonly userService: UserService, private readonly userLoginMigrationRepo: UserLoginMigrationRepo, private readonly schoolService: LegacySchoolService, - private readonly systemService: SystemService, - private readonly schoolMigrationService: SchoolMigrationService + private readonly systemService: SystemService ) {} - /** - * @deprecated Use the other functions in this class instead. - * - * @param schoolId - * @param oauthMigrationPossible - * @param oauthMigrationMandatory - * @param oauthMigrationFinished - */ - async setMigration( - schoolId: EntityId, - oauthMigrationPossible?: boolean, - oauthMigrationMandatory?: boolean, - oauthMigrationFinished?: boolean - ): Promise { - const schoolDo: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); - - const existingUserLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId( - schoolId - ); - - let userLoginMigration: UserLoginMigrationDO; - - if (existingUserLoginMigration) { - userLoginMigration = existingUserLoginMigration; - } else { - if (!oauthMigrationPossible) { - throw new UnprocessableEntityException(`School ${schoolId} has no UserLoginMigration`); - } - - userLoginMigration = await this.createNewMigration(schoolDo); - - this.enableOauthMigrationFeature(schoolDo); - await this.schoolService.save(schoolDo); - } - - if (oauthMigrationPossible === true) { - userLoginMigration.closedAt = undefined; - userLoginMigration.finishedAt = undefined; - } - - if (oauthMigrationMandatory !== undefined) { - userLoginMigration.mandatorySince = oauthMigrationMandatory ? new Date() : undefined; - } - - if (oauthMigrationFinished !== undefined) { - userLoginMigration.closedAt = oauthMigrationFinished ? new Date() : undefined; - userLoginMigration.finishedAt = oauthMigrationFinished - ? new Date(Date.now() + (Configuration.get('MIGRATION_END_GRACE_PERIOD_MS') as number)) - : undefined; - } - - const savedMigration: UserLoginMigrationDO = await this.userLoginMigrationRepo.save(userLoginMigration); - - if (oauthMigrationFinished !== undefined) { - // this would throw an error when executed before the userLoginMigrationRepo.save method. - await this.schoolService.removeFeature(schoolId, SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION); - } - - return savedMigration; - } - - async startMigration(schoolId: string): Promise { + public async startMigration(schoolId: string): Promise { const schoolDo: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); const userLoginMigrationDO: UserLoginMigrationDO = await this.createNewMigration(schoolDo); @@ -92,27 +32,29 @@ export class UserLoginMigrationService { return userLoginMigration; } - async restartMigration(schoolId: string): Promise { - const existingUserLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId( - schoolId - ); + public async restartMigration(userLoginMigration: UserLoginMigrationDO): Promise { + this.checkGracePeriod(userLoginMigration); - if (!existingUserLoginMigration) { - throw new UserLoginMigrationNotFoundLoggableException(schoolId); + if (!userLoginMigration.closedAt || !userLoginMigration.finishedAt) { + return userLoginMigration; } - const updatedUserLoginMigration = await this.updateExistingMigration(existingUserLoginMigration); + userLoginMigration.closedAt = undefined; + userLoginMigration.finishedAt = undefined; - await this.schoolMigrationService.unmarkOutdatedUsers(schoolId); + const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationRepo.save(userLoginMigration); return updatedUserLoginMigration; } - async setMigrationMandatory(schoolId: string, mandatory: boolean): Promise { - let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); + public async setMigrationMandatory( + userLoginMigration: UserLoginMigrationDO, + mandatory: boolean + ): Promise { + this.checkGracePeriod(userLoginMigration); - if (!userLoginMigration) { - throw new UserLoginMigrationNotFoundLoggableException(schoolId); + if (userLoginMigration.closedAt) { + throw new UserLoginMigrationAlreadyClosedLoggableException(userLoginMigration.closedAt, userLoginMigration.id); } if (mandatory) { @@ -126,14 +68,17 @@ export class UserLoginMigrationService { return userLoginMigration; } - async closeMigration(schoolId: string): Promise { - let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); + public async closeMigration(userLoginMigration: UserLoginMigrationDO): Promise { + this.checkGracePeriod(userLoginMigration); - if (!userLoginMigration) { - throw new UserLoginMigrationNotFoundLoggableException(schoolId); + if (userLoginMigration.closedAt) { + return userLoginMigration; } - await this.schoolService.removeFeature(schoolId, SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION); + await this.schoolService.removeFeature( + userLoginMigration.schoolId, + SchoolFeatures.ENABLE_LDAP_SYNC_DURING_MIGRATION + ); const now: Date = new Date(); const gracePeriodDuration: number = Configuration.get('MIGRATION_END_GRACE_PERIOD_MS') as number; @@ -146,6 +91,22 @@ export class UserLoginMigrationService { return userLoginMigration; } + private checkGracePeriod(userLoginMigration: UserLoginMigrationDO) { + if (userLoginMigration.finishedAt && this.isGracePeriodExpired(userLoginMigration)) { + throw new UserLoginMigrationGracePeriodExpiredLoggableException( + userLoginMigration.id as string, + userLoginMigration.finishedAt + ); + } + } + + private isGracePeriodExpired(userLoginMigration: UserLoginMigrationDO): boolean { + const isGracePeriodExpired: boolean = + !!userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime(); + + return isGracePeriodExpired; + } + private async createNewMigration(school: LegacySchoolDo): Promise { const oauthSystems: SystemDto[] = await this.systemService.findByType(SystemTypeEnum.OAUTH); const sanisSystem: SystemDto | undefined = oauthSystems.find((system: SystemDto) => system.alias === 'SANIS'); @@ -168,15 +129,6 @@ export class UserLoginMigrationService { return userLoginMigrationDO; } - private async updateExistingMigration(userLoginMigrationDO: UserLoginMigrationDO) { - userLoginMigrationDO.closedAt = undefined; - userLoginMigrationDO.finishedAt = undefined; - - const userLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationRepo.save(userLoginMigrationDO); - - return userLoginMigration; - } - private enableOauthMigrationFeature(schoolDo: LegacySchoolDo) { if (schoolDo.features && !schoolDo.features.includes(SchoolFeatures.OAUTH_PROVISIONING_ENABLED)) { schoolDo.features.push(SchoolFeatures.OAUTH_PROVISIONING_ENABLED); @@ -185,13 +137,13 @@ export class UserLoginMigrationService { } } - async findMigrationBySchool(schoolId: string): Promise { + public async findMigrationBySchool(schoolId: string): Promise { const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); return userLoginMigration; } - async findMigrationByUser(userId: EntityId): Promise { + public async findMigrationByUser(userId: EntityId): Promise { const userDO: UserDO = await this.userService.findById(userId); const { schoolId } = userDO; @@ -211,7 +163,7 @@ export class UserLoginMigrationService { return userLoginMigration; } - async deleteUserLoginMigration(userLoginMigration: UserLoginMigrationDO): Promise { + public async deleteUserLoginMigration(userLoginMigration: UserLoginMigrationDO): Promise { await this.userLoginMigrationRepo.delete(userLoginMigration); } } diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts index c098066664d..e738a1feac3 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts @@ -1,55 +1,27 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { ObjectId } from '@mikro-orm/mongodb'; -import { BadRequestException, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, RoleName, UserDO } from '@shared/domain'; -import { legacySchoolDoFactory, setupEntities, userDoFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto, AccountSaveDto } from '@modules/account/services/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemService } from '@modules/system'; -import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { AccountDto } from '@modules/account/services/dto'; import { UserService } from '@modules/user'; -import { PageTypes } from '../interface/page-types.enum'; -import { PageContentDto } from './dto'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain'; +import { roleFactory, setupEntities, userDoFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { UserMigrationDatabaseOperationFailedLoggableException } from '../loggable'; import { UserMigrationService } from './user-migration.service'; -describe('UserMigrationService', () => { +describe(UserMigrationService.name, () => { let module: TestingModule; let service: UserMigrationService; - let configBefore: IConfig; - let logger: LegacyLogger; - let schoolService: DeepMocked; - let systemService: DeepMocked; let userService: DeepMocked; let accountService: DeepMocked; - - const hostUri = 'http://this.de'; - const apiUrl = 'http://mock.de'; - const s3 = 'sKey123456789123456789'; + let logger: DeepMocked; beforeAll(async () => { - configBefore = Configuration.toObject({ plainSecrets: true }); - Configuration.set('HOST', hostUri); - Configuration.set('PUBLIC_BACKEND_URL', apiUrl); - Configuration.set('S3_KEY', s3); - module = await Test.createTestingModule({ providers: [ UserMigrationService, - { - provide: LegacySchoolService, - useValue: createMock(), - }, - { - provide: SystemService, - useValue: createMock(), - }, { provide: UserService, useValue: createMock(), @@ -59,398 +31,204 @@ describe('UserMigrationService', () => { useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, ], }).compile(); service = module.get(UserMigrationService); - schoolService = module.get(LegacySchoolService); - systemService = module.get(SystemService); userService = module.get(UserService); accountService = module.get(AccountService); - logger = module.get(LegacyLogger); + logger = module.get(Logger); await setupEntities(); }); afterAll(async () => { await module.close(); - - Configuration.reset(configBefore); }); afterEach(() => { jest.resetAllMocks(); }); - describe('getMigrationConsentPageRedirect is called', () => { - describe('when finding the migration systems', () => { - const setup = () => { - const officialSchoolNumber = '3'; - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ name: 'schoolName', officialSchoolNumber }); - - schoolService.getSchoolBySchoolNumber.mockResolvedValue(school); - - return { - officialSchoolNumber, - }; - }; + describe('migrateUser', () => { + const mockDate = new Date(2020, 1, 1); - it('should return a url to the migration endpoint', async () => { - const { officialSchoolNumber } = setup(); - - const result: string = await service.getMigrationConsentPageRedirect(officialSchoolNumber, 'iservId'); - - expect(result).toEqual('http://this.de/migration?origin=iservId'); - }); + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockDate); }); - describe('when the school was not found', () => { + describe('when migrate user was successful', () => { const setup = () => { - const officialSchoolNumber = '3'; + const targetSystemId = new ObjectId().toHexString(); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(null); + const role = roleFactory.buildWithId(); + const userId = new ObjectId().toHexString(); + const targetExternalId = 'newUserExternalId'; + const sourceExternalId = 'currentUserExternalId'; + const user: UserDO = userDoFactory.buildWithId({ + id: userId, + createdAt: mockDate, + updatedAt: mockDate, + email: 'emailMock', + firstName: 'firstNameMock', + lastName: 'lastNameMock', + schoolId: 'schoolMock', + roles: [role], + externalId: sourceExternalId, + }); + + const accountId = new ObjectId().toHexString(); + const sourceSystemId = new ObjectId().toHexString(); + const accountDto: AccountDto = new AccountDto({ + id: accountId, + updatedAt: new Date(), + createdAt: new Date(), + userId, + username: '', + systemId: sourceSystemId, + }); + + userService.findById.mockResolvedValueOnce({ ...user }); + accountService.findByUserIdOrFail.mockResolvedValueOnce({ ...accountDto }); return { - officialSchoolNumber, + user, + userId, + targetExternalId, + sourceExternalId, + accountDto, + sourceSystemId, + targetSystemId, }; }; - it('should throw InternalServerErrorException', async () => { - const { officialSchoolNumber } = setup(); - schoolService.getSchoolBySchoolNumber.mockResolvedValue(null); + it('should use the correct user', async () => { + const { userId, targetExternalId, targetSystemId } = setup(); - const promise: Promise = service.getMigrationConsentPageRedirect(officialSchoolNumber, 'systemId'); + await service.migrateUser(userId, targetExternalId, targetSystemId); - await expect(promise).rejects.toThrow(NotFoundException); + expect(userService.findById).toHaveBeenCalledWith(userId); }); - }); - }); - describe('getMigrationRedirectUri is called', () => { - describe('when a Redirect-URL for a system is requested', () => { - it('should return a proper redirect', () => { - const response = service.getMigrationRedirectUri(); + it('should use the correct account', async () => { + const { userId, targetExternalId, targetSystemId } = setup(); - expect(response).toContain('migration'); - }); - }); - }); + await service.migrateUser(userId, targetExternalId, targetSystemId); - describe('getPageContent is called', () => { - const setupPageContent = () => { - const sourceOauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: 'sourceClientId', - clientSecret: 'sourceSecret', - tokenEndpoint: 'http://source.de/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://source.de/auth', - provider: 'source_provider', - logoutEndpoint: 'source_logoutEndpoint', - issuer: 'source_issuer', - jwksEndpoint: 'source_jwksEndpoint', - redirectUri: 'http://this.de/api/v3/sso/oauth/sourceSystemId', - }); - const targetOauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: 'targetClientId', - clientSecret: 'targetSecret', - tokenEndpoint: 'http://target.de/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://target.de/auth', - provider: 'target_provider', - logoutEndpoint: 'target_logoutEndpoint', - issuer: 'target_issuer', - jwksEndpoint: 'target_jwksEndpoint', - redirectUri: 'http://this.de/api/v3/sso/oauth/targetSystemId', - }); - const sourceSystem: SystemDto = new SystemDto({ - id: 'sourceSystemId', - type: 'oauth', - alias: 'Iserv', - oauthConfig: sourceOauthConfig, - }); - const targetSystem: SystemDto = new SystemDto({ - id: 'targetSystemId', - type: 'oauth', - alias: 'Sanis', - oauthConfig: targetOauthConfig, + expect(accountService.findByUserIdOrFail).toHaveBeenCalledWith(userId); }); - const migrationRedirectUri = 'http://mock.de/api/v3/sso/oauth/targetSystemId/migration'; + it('should save the migrated user', async () => { + const { userId, targetExternalId, targetSystemId, user, sourceExternalId } = setup(); - return { sourceSystem, targetSystem, sourceOauthConfig, targetOauthConfig, migrationRedirectUri }; - }; + await service.migrateUser(userId, targetExternalId, targetSystemId); - describe('when coming from the target system', () => { - it('should return the url to the source system and a frontpage url', async () => { - const { sourceSystem, targetSystem } = setupPageContent(); - const sourceSystemLoginUrl = `http://mock.de/api/v3/sso/login/sourceSystemId?postLoginRedirect=http%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Flogin%2FtargetSystemId%3Fmigration%3Dtrue`; - - systemService.findById.mockResolvedValueOnce(sourceSystem); - systemService.findById.mockResolvedValueOnce(targetSystem); - - const contentDto: PageContentDto = await service.getPageContent( - PageTypes.START_FROM_TARGET_SYSTEM, - sourceSystem.id as string, - targetSystem.id as string - ); - - expect(contentDto).toEqual({ - proceedButtonUrl: sourceSystemLoginUrl, - cancelButtonUrl: '/login', + expect(userService.save).toHaveBeenCalledWith({ + ...user, + externalId: targetExternalId, + previousExternalId: sourceExternalId, + lastLoginSystemChange: mockDate, }); }); - }); - - describe('when coming from the source system', () => { - it('should return the url to the target system and a dashboard url', async () => { - const { sourceSystem, targetSystem } = setupPageContent(); - const targetSystemLoginUrl = `http://mock.de/api/v3/sso/login/targetSystemId?migration=true`; - systemService.findById.mockResolvedValueOnce(sourceSystem); - systemService.findById.mockResolvedValueOnce(targetSystem); + it('should save the migrated account', async () => { + const { userId, targetExternalId, targetSystemId, accountDto } = setup(); - const contentDto: PageContentDto = await service.getPageContent( - PageTypes.START_FROM_SOURCE_SYSTEM, - sourceSystem.id as string, - targetSystem.id as string - ); + await service.migrateUser(userId, targetExternalId, targetSystemId); - expect(contentDto).toEqual({ - proceedButtonUrl: targetSystemLoginUrl, - cancelButtonUrl: '/dashboard', + expect(accountService.save).toHaveBeenCalledWith({ + ...accountDto, + systemId: targetSystemId, }); }); }); - describe('when coming from the source system and the migration is mandatory', () => { - it('should return the url to the target system and a logout url', async () => { - const { sourceSystem, targetSystem } = setupPageContent(); - const targetSystemLoginUrl = `http://mock.de/api/v3/sso/login/targetSystemId?migration=true`; - - systemService.findById.mockResolvedValueOnce(sourceSystem); - systemService.findById.mockResolvedValueOnce(targetSystem); - - const contentDto: PageContentDto = await service.getPageContent( - PageTypes.START_FROM_SOURCE_SYSTEM_MANDATORY, - sourceSystem.id as string, - targetSystem.id as string - ); + describe('when saving to the database fails', () => { + const setup = () => { + const targetSystemId = new ObjectId().toHexString(); - expect(contentDto).toEqual({ - proceedButtonUrl: targetSystemLoginUrl, - cancelButtonUrl: '/logout', + const role = roleFactory.buildWithId(); + const userId = new ObjectId().toHexString(); + const targetExternalId = 'newUserExternalId'; + const sourceExternalId = 'currentUserExternalId'; + const user: UserDO = userDoFactory.buildWithId({ + id: userId, + createdAt: mockDate, + updatedAt: mockDate, + email: 'emailMock', + firstName: 'firstNameMock', + lastName: 'lastNameMock', + schoolId: 'schoolMock', + roles: [role], + externalId: sourceExternalId, }); - }); - }); - describe('when a wrong page type is given', () => { - it('throws a BadRequestException', async () => { - const { sourceSystem, targetSystem } = setupPageContent(); - systemService.findById.mockResolvedValueOnce(sourceSystem); - systemService.findById.mockResolvedValueOnce(targetSystem); - - const promise: Promise = service.getPageContent('undefined' as PageTypes, '', ''); - - await expect(promise).rejects.toThrow(BadRequestException); - }); - }); - - describe('when a system has no oauth config', () => { - it('throws a EntityNotFoundError', async () => { - const { sourceSystem, targetSystem } = setupPageContent(); - sourceSystem.oauthConfig = undefined; - systemService.findById.mockResolvedValueOnce(sourceSystem); - systemService.findById.mockResolvedValueOnce(targetSystem); - - const promise: Promise = service.getPageContent( - PageTypes.START_FROM_TARGET_SYSTEM, - 'invalid', - 'invalid' - ); - - await expect(promise).rejects.toThrow(UnprocessableEntityException); - }); - }); - }); - - describe('migrateUser is called', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2020, 1, 1)); - }); - - const setupMigrationData = () => { - const targetSystemId = new ObjectId().toHexString(); - - const notMigratedUser: UserDO = userDoFactory - .withRoles([{ id: 'roleIdMock', name: RoleName.STUDENT }]) - .buildWithId( - { - createdAt: new Date(), - updatedAt: new Date(), - email: 'emailMock', - firstName: 'firstNameMock', - lastName: 'lastNameMock', - schoolId: 'schoolMock', - externalId: 'currentUserExternalIdMock', - }, - 'userId' - ); + const accountId = new ObjectId().toHexString(); + const sourceSystemId = new ObjectId().toHexString(); + const accountDto: AccountDto = new AccountDto({ + id: accountId, + updatedAt: new Date(), + createdAt: new Date(), + userId, + username: '', + systemId: sourceSystemId, + }); - const migratedUserDO: UserDO = userDoFactory - .withRoles([{ id: 'roleIdMock', name: RoleName.STUDENT }]) - .buildWithId( - { - createdAt: new Date(), - updatedAt: new Date(), - email: 'emailMock', - firstName: 'firstNameMock', - lastName: 'lastNameMock', - schoolId: 'schoolMock', - externalId: 'externalUserTargetId', - previousExternalId: 'currentUserExternalIdMock', - lastLoginSystemChange: new Date(), - }, - 'userId' - ); + const error = new Error('Cannot save'); - const accountId = new ObjectId().toHexString(); - const userId = new ObjectId().toHexString(); - const sourceSystemId = new ObjectId().toHexString(); - - const accountDto: AccountDto = new AccountDto({ - id: accountId, - updatedAt: new Date(), - createdAt: new Date(), - userId, - username: '', - systemId: sourceSystemId, - }); + userService.findById.mockResolvedValueOnce({ ...user }); + accountService.findByUserIdOrFail.mockResolvedValueOnce({ ...accountDto }); - const migratedAccount: AccountSaveDto = new AccountSaveDto({ - id: accountId, - updatedAt: new Date(), - createdAt: new Date(), - userId, - username: '', - systemId: targetSystemId, - }); + userService.save.mockRejectedValueOnce(error); + accountService.save.mockRejectedValueOnce(error); - return { - accountDto, - migratedUserDO, - notMigratedUser, - migratedAccount, - sourceSystemId, - targetSystemId, + return { + user, + userId, + targetExternalId, + sourceExternalId, + accountDto, + sourceSystemId, + targetSystemId, + error, + }; }; - }; - - describe('when migrate user was successful', () => { - it('should return to migration succeed page', async () => { - const { targetSystemId, sourceSystemId, accountDto } = setupMigrationData(); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); - - const result = await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); - - expect(result.redirect).toStrictEqual( - `${hostUri}/migration/success?sourceSystem=${sourceSystemId}&targetSystem=${targetSystemId}` - ); - }); - it('should call methods of migration ', async () => { - const { migratedUserDO, migratedAccount, targetSystemId, notMigratedUser, accountDto } = setupMigrationData(); - userService.findById.mockResolvedValue(notMigratedUser); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); + it('should roll back possible changes to the user', async () => { + const { userId, targetExternalId, targetSystemId, user } = setup(); - await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); + await expect(service.migrateUser(userId, targetExternalId, targetSystemId)).rejects.toThrow(); - expect(userService.findById).toHaveBeenCalledWith('userId'); - expect(userService.save).toHaveBeenCalledWith(migratedUserDO); - expect(accountService.findByUserIdOrFail).toHaveBeenCalledWith('userId'); - expect(accountService.save).toHaveBeenCalledWith(migratedAccount); + expect(userService.save).toHaveBeenLastCalledWith(user); }); - it('should do migration of user', async () => { - const { migratedUserDO, notMigratedUser, accountDto, targetSystemId } = setupMigrationData(); - userService.findById.mockResolvedValue(notMigratedUser); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); + it('should roll back possible changes to the account', async () => { + const { userId, targetExternalId, targetSystemId, accountDto } = setup(); - await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); + await expect(service.migrateUser(userId, targetExternalId, targetSystemId)).rejects.toThrow(); - expect(userService.save).toHaveBeenCalledWith(migratedUserDO); + expect(accountService.save).toHaveBeenLastCalledWith(accountDto); }); - it('should do migration of account', async () => { - const { notMigratedUser, accountDto, migratedAccount, targetSystemId } = setupMigrationData(); - userService.findById.mockResolvedValue(notMigratedUser); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); - - await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); + it('should log a rollback error', async () => { + const { userId, targetExternalId, targetSystemId, error } = setup(); - expect(accountService.save).toHaveBeenCalledWith(migratedAccount); - }); - }); - - describe('when migration step failed', () => { - it('should throw Error', async () => { - const targetSystemId = new ObjectId().toHexString(); - userService.findById.mockRejectedValue(new NotFoundException('Could not find User')); + await expect(service.migrateUser(userId, targetExternalId, targetSystemId)).rejects.toThrow(); - await expect(service.migrateUser('userId', 'externalUserTargetId', targetSystemId)).rejects.toThrow( - new NotFoundException('Could not find User') + expect(logger.warning).toHaveBeenCalledWith( + new UserMigrationDatabaseOperationFailedLoggableException(userId, 'rollback', error) ); }); - it('should log error and message', async () => { - const { migratedUserDO, accountDto, targetSystemId } = setupMigrationData(); - const error = new NotFoundException('Test Error'); - userService.findById.mockResolvedValue(migratedUserDO); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); - accountService.save.mockRejectedValueOnce(error); - - await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); - - expect(logger.log).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'This error occurred during migration of User:', - affectedUserId: 'userId', - error, - }) - ); - }); - - it('should do a rollback of migration', async () => { - const { notMigratedUser, accountDto, targetSystemId } = setupMigrationData(); - const error = new NotFoundException('Test Error'); - userService.findById.mockResolvedValue(notMigratedUser); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); - accountService.save.mockRejectedValueOnce(error); - - await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); - - expect(userService.save).toHaveBeenCalledWith(notMigratedUser); - expect(accountService.save).toHaveBeenCalledWith(accountDto); - }); - - it('should return to dashboard', async () => { - const { migratedUserDO, accountDto, targetSystemId, sourceSystemId } = setupMigrationData(); - const error = new NotFoundException('Test Error'); - userService.findById.mockResolvedValue(migratedUserDO); - accountService.findByUserIdOrFail.mockResolvedValue(accountDto); - accountService.save.mockRejectedValueOnce(error); - - const result = await service.migrateUser('userId', 'externalUserTargetId', targetSystemId); + it('should throw an error', async () => { + const { userId, targetExternalId, targetSystemId, error } = setup(); - expect(result.redirect).toStrictEqual( - `${hostUri}/migration/error?sourceSystem=${sourceSystemId}&targetSystem=${targetSystemId}` + await expect(service.migrateUser(userId, targetExternalId, targetSystemId)).rejects.toThrow( + new UserMigrationDatabaseOperationFailedLoggableException(userId, 'migration', error) ); }); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts index c9a6e8648c8..38c020b43cb 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts @@ -1,144 +1,42 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { LegacySchoolDo } from '@shared/domain'; -import { UserDO } from '@shared/domain/domainobject/user.do'; -import { LegacyLogger } from '@src/core/logger'; import { AccountService } from '@modules/account/services/account.service'; import { AccountDto } from '@modules/account/services/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemDto, SystemService } from '@modules/system/service'; import { UserService } from '@modules/user'; -import { EntityId } from '@src/shared/domain/types'; -import { PageTypes } from '../interface/page-types.enum'; -import { MigrationDto } from './dto/migration.dto'; -import { PageContentDto } from './dto/page-content.dto'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { UserDO } from '@shared/domain/domainobject/user.do'; +import { Logger } from '@src/core/logger'; +import { UserMigrationDatabaseOperationFailedLoggableException } from '../loggable'; @Injectable() -/** - * @deprecated - */ export class UserMigrationService { - private readonly hostUrl: string; - - private readonly publicBackendUrl: string; - - private readonly dashboardUrl: string = '/dashboard'; - - private readonly logoutUrl: string = '/logout'; - - private readonly loginUrl: string = '/login'; - constructor( - private readonly schoolService: LegacySchoolService, - private readonly systemService: SystemService, private readonly userService: UserService, - private readonly logger: LegacyLogger, - private readonly accountService: AccountService - ) { - this.hostUrl = Configuration.get('HOST') as string; - this.publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; - } - - async getMigrationConsentPageRedirect(officialSchoolNumber: string, originSystemId: string): Promise { - const school: LegacySchoolDo | null = await this.schoolService.getSchoolBySchoolNumber(officialSchoolNumber); - - if (!school || !school.id) { - throw new NotFoundException(`School with offical school number ${officialSchoolNumber} does not exist.`); - } - - const url = new URL('/migration', this.hostUrl); - url.searchParams.append('origin', originSystemId); - return url.toString(); - } - - async getPageContent(pageType: PageTypes, sourceId: string, targetId: string): Promise { - const sourceSystem: SystemDto = await this.systemService.findById(sourceId); - const targetSystem: SystemDto = await this.systemService.findById(targetId); - - const targetSystemLoginUrl: string = this.getLoginUrl(targetSystem); - - switch (pageType) { - case PageTypes.START_FROM_TARGET_SYSTEM: { - const sourceSystemLoginUrl: string = this.getLoginUrl(sourceSystem, targetSystemLoginUrl.toString()); - - const content: PageContentDto = new PageContentDto({ - proceedButtonUrl: sourceSystemLoginUrl.toString(), - cancelButtonUrl: this.loginUrl, - }); - return content; - } - case PageTypes.START_FROM_SOURCE_SYSTEM: { - const content: PageContentDto = new PageContentDto({ - proceedButtonUrl: targetSystemLoginUrl.toString(), - cancelButtonUrl: this.dashboardUrl, - }); - return content; - } - case PageTypes.START_FROM_SOURCE_SYSTEM_MANDATORY: { - const content: PageContentDto = new PageContentDto({ - proceedButtonUrl: targetSystemLoginUrl.toString(), - cancelButtonUrl: this.logoutUrl, - }); - return content; - } - default: { - throw new BadRequestException('Unknown PageType requested'); - } - } - } + private readonly accountService: AccountService, + private readonly logger: Logger + ) {} - // TODO: https://ticketsystem.dbildungscloud.de/browse/N21-632 Move Redirect Logic URLs to Client - getMigrationRedirectUri(): string { - const combinedUri = new URL(this.publicBackendUrl); - combinedUri.pathname = `api/v3/sso/oauth/migration`; - return combinedUri.toString(); - } - - async migrateUser(currentUserId: string, externalUserId: string, targetSystemId: string): Promise { + async migrateUser(currentUserId: EntityId, externalUserId: string, targetSystemId: EntityId): Promise { const userDO: UserDO = await this.userService.findById(currentUserId); const account: AccountDto = await this.accountService.findByUserIdOrFail(currentUserId); + const userDOCopy: UserDO = new UserDO({ ...userDO }); const accountCopy: AccountDto = new AccountDto({ ...account }); - let migrationDto: MigrationDto; try { - migrationDto = await this.doMigration(userDO, externalUserId, account, targetSystemId, accountCopy.systemId); - } catch (e: unknown) { - this.logger.log({ - message: 'This error occurred during migration of User:', - affectedUserId: currentUserId, - error: e, - }); + await this.doMigration(userDO, externalUserId, account, targetSystemId); + } catch (error: unknown) { + await this.tryRollbackMigration(currentUserId, userDOCopy, accountCopy); - migrationDto = await this.rollbackMigration(userDOCopy, accountCopy, targetSystemId); + throw new UserMigrationDatabaseOperationFailedLoggableException(currentUserId, 'migration', error); } - - return migrationDto; - } - - private async rollbackMigration( - userDOCopy: UserDO, - accountCopy: AccountDto, - targetSystemId: string - ): Promise { - await this.userService.save(userDOCopy); - await this.accountService.save(accountCopy); - - const userMigrationDto: MigrationDto = this.createUserMigrationDto( - '/migration/error', - accountCopy.systemId ?? '', - targetSystemId - ); - return userMigrationDto; } private async doMigration( userDO: UserDO, externalUserId: string, account: AccountDto, - targetSystemId: string, - accountId?: EntityId - ): Promise { + targetSystemId: string + ): Promise { userDO.previousExternalId = userDO.externalId; userDO.externalId = externalUserId; userDO.lastLoginSystemChange = new Date(); @@ -146,38 +44,18 @@ export class UserMigrationService { account.systemId = targetSystemId; await this.accountService.save(account); - - const userMigrationDto: MigrationDto = this.createUserMigrationDto( - '/migration/success', - accountId ?? '', - targetSystemId - ); - return userMigrationDto; } - // TODO: https://ticketsystem.dbildungscloud.de/browse/N21-632 Move Redirect Logic URLs to Client - private createUserMigrationDto(urlPath: string, sourceSystemId: string, targetSystemId: string) { - const errorUrl: URL = new URL(urlPath, this.hostUrl); - errorUrl.searchParams.append('sourceSystem', sourceSystemId); - errorUrl.searchParams.append('targetSystem', targetSystemId); - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: errorUrl.toString(), - }); - return userMigrationDto; - } - - private getLoginUrl(system: SystemDto, postLoginRedirect?: string): string { - if (!system.oauthConfig || !system.id) { - throw new UnprocessableEntityException(`System ${system?.id || 'unknown'} has no oauth config`); - } - - const loginUrl: URL = new URL(`api/v3/sso/login/${system.id}`, this.publicBackendUrl); - if (postLoginRedirect) { - loginUrl.searchParams.append('postLoginRedirect', postLoginRedirect); - } else { - loginUrl.searchParams.append('migration', 'true'); + private async tryRollbackMigration( + currentUserId: EntityId, + userDOCopy: UserDO, + accountCopy: AccountDto + ): Promise { + try { + await this.userService.save(userDOCopy); + await this.accountService.save(accountCopy); + } catch (error: unknown) { + this.logger.warning(new UserMigrationDatabaseOperationFailedLoggableException(currentUserId, 'rollback', error)); } - - return loginUrl.toString(); } } diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts index b14ab751d40..a3eead10096 100644 --- a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts @@ -1,13 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, UserLoginMigrationDO } from '@shared/domain'; import { setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; -import { Action, AuthorizationService } from '@modules/authorization'; -import { UserLoginMigrationNotFoundLoggableException } from '../error'; +import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { SchoolMigrationService, UserLoginMigrationRevertService, UserLoginMigrationService } from '../service'; import { CloseUserLoginMigrationUc } from './close-user-login-migration.uc'; -describe('CloseUserLoginMigrationUc', () => { +describe(CloseUserLoginMigrationUc.name, () => { let module: TestingModule; let uc: CloseUserLoginMigrationUc; @@ -65,12 +66,12 @@ describe('CloseUserLoginMigrationUc', () => { ...userLoginMigration, closedAt: new Date(2023, 1), }); - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - userLoginMigrationService.closeMigration.mockResolvedValue(closedUserLoginMigration); - schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(true); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userLoginMigrationService.closeMigration.mockResolvedValueOnce(closedUserLoginMigration); + schoolMigrationService.hasSchoolMigratedUser.mockResolvedValueOnce(true); return { user, @@ -85,26 +86,27 @@ describe('CloseUserLoginMigrationUc', () => { await uc.closeMigration(user.id, schoolId); - expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, userLoginMigration, { - requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], - action: Action.write, - }); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + userLoginMigration, + AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) + ); }); it('should close the migration', async () => { - const { user, schoolId } = setup(); + const { user, schoolId, userLoginMigration } = setup(); await uc.closeMigration(user.id, schoolId); - expect(userLoginMigrationService.closeMigration).toHaveBeenCalledWith(schoolId); + expect(userLoginMigrationService.closeMigration).toHaveBeenCalledWith(userLoginMigration); }); it('should mark all un-migrated users as outdated', async () => { - const { user, schoolId } = setup(); + const { user, schoolId, closedUserLoginMigration } = setup(); await uc.closeMigration(user.id, schoolId); - expect(schoolMigrationService.markUnmigratedUsersAsOutdated).toHaveBeenCalledWith(schoolId); + expect(schoolMigrationService.markUnmigratedUsersAsOutdated).toHaveBeenCalledWith(closedUserLoginMigration); }); it('should return the closed user login migration', async () => { @@ -119,9 +121,9 @@ describe('CloseUserLoginMigrationUc', () => { describe('when no user login migration exists', () => { const setup = () => { const user = userFactory.buildWithId(); - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); return { user, @@ -148,10 +150,10 @@ describe('CloseUserLoginMigrationUc', () => { }); const schoolId = 'schoolId'; - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - userLoginMigrationService.closeMigration.mockResolvedValue(closedUserLoginMigration); - schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userLoginMigrationService.closeMigration.mockResolvedValueOnce(closedUserLoginMigration); + schoolMigrationService.hasSchoolMigratedUser.mockResolvedValueOnce(false); return { user, @@ -166,10 +168,11 @@ describe('CloseUserLoginMigrationUc', () => { await uc.closeMigration(user.id, schoolId); - expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, userLoginMigration, { - requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], - action: Action.write, - }); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + userLoginMigration, + AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) + ); }); it('should revert the start of the migration', async () => { @@ -188,7 +191,7 @@ describe('CloseUserLoginMigrationUc', () => { expect(schoolMigrationService.markUnmigratedUsersAsOutdated).not.toHaveBeenCalled(); }); - it('should return undefined', async () => { + it('should return undefined', async () => { const { user, schoolId } = setup(); const result = await uc.closeMigration(user.id, schoolId); @@ -196,41 +199,5 @@ describe('CloseUserLoginMigrationUc', () => { expect(result).toBeUndefined(); }); }); - - describe('when the user login migration was already closed', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ - closedAt: new Date(2023, 1), - }); - const schoolId = 'schoolId'; - - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); - - return { - user, - schoolId, - userLoginMigration, - }; - }; - - it('should not modify the user login migration', async () => { - const { user, schoolId } = setup(); - - await uc.closeMigration(user.id, schoolId); - - expect(userLoginMigrationService.closeMigration).not.toHaveBeenCalled(); - }); - - it('should return the already closed user login migration', async () => { - const { user, schoolId, userLoginMigration } = setup(); - - const result = await uc.closeMigration(user.id, schoolId); - - expect(result).toEqual(userLoginMigration); - }); - }); }); }); diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts index 65bdad24782..c04064f813a 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 @@ -1,10 +1,7 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; import { EntityId, Permission, User, UserLoginMigrationDO } from '@shared/domain'; -import { Action, AuthorizationService } from '@modules/authorization'; -import { - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../error'; +import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { SchoolMigrationService, UserLoginMigrationRevertService, UserLoginMigrationService } from '../service'; @Injectable() @@ -26,39 +23,26 @@ export class CloseUserLoginMigrationUc { } const user: User = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission(user, userLoginMigration, { - requiredPermissions: [Permission.USER_LOGIN_MIGRATION_ADMIN], - action: Action.write, - }); + this.authorizationService.checkPermission( + user, + userLoginMigration, + AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) + ); - if (userLoginMigration.finishedAt && this.isGracePeriodExpired(userLoginMigration)) { - throw new UserLoginMigrationGracePeriodExpiredLoggableException( - userLoginMigration.id as string, - userLoginMigration.finishedAt - ); - } else if (userLoginMigration.closedAt) { - return userLoginMigration; - } else { - const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.closeMigration( - schoolId - ); + const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.closeMigration( + userLoginMigration + ); - const hasSchoolMigratedUser: boolean = await this.schoolMigrationService.hasSchoolMigratedUser(schoolId); + const hasSchoolMigratedUser: boolean = await this.schoolMigrationService.hasSchoolMigratedUser(schoolId); - if (!hasSchoolMigratedUser) { - await this.userLoginMigrationRevertService.revertUserLoginMigration(updatedUserLoginMigration); - return undefined; - } - await this.schoolMigrationService.markUnmigratedUsersAsOutdated(schoolId); + if (!hasSchoolMigratedUser) { + await this.userLoginMigrationRevertService.revertUserLoginMigration(updatedUserLoginMigration); - return updatedUserLoginMigration; + return undefined; } - } - private isGracePeriodExpired(userLoginMigration: UserLoginMigrationDO): boolean { - const isGracePeriodExpired: boolean = - !!userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime(); + await this.schoolMigrationService.markUnmigratedUsersAsOutdated(updatedUserLoginMigration); - return isGracePeriodExpired; + return updatedUserLoginMigration; } } diff --git a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.spec.ts index dd4ac4f835b..1aace730ee4 100644 --- a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.spec.ts @@ -1,25 +1,21 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; -import { legacySchoolDoFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../error'; -import { UserLoginMigrationService } from '../service'; +import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; +import { SchoolMigrationService, UserLoginMigrationService } from '../service'; import { RestartUserLoginMigrationUc } from './restart-user-login-migration.uc'; -describe('RestartUserLoginMigrationUc', () => { +describe(RestartUserLoginMigrationUc.name, () => { let module: TestingModule; let uc: RestartUserLoginMigrationUc; let userLoginMigrationService: DeepMocked; let authorizationService: DeepMocked; - let schoolService: DeepMocked; + let schoolMigrationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -34,8 +30,8 @@ describe('RestartUserLoginMigrationUc', () => { useValue: createMock(), }, { - provide: LegacySchoolService, - useValue: createMock(), + provide: SchoolMigrationService, + useValue: createMock(), }, { provide: Logger, @@ -47,7 +43,7 @@ describe('RestartUserLoginMigrationUc', () => { uc = module.get(RestartUserLoginMigrationUc); userLoginMigrationService = module.get(UserLoginMigrationService); authorizationService = module.get(AuthorizationService); - schoolService = module.get(LegacySchoolService); + schoolMigrationService = module.get(SchoolMigrationService); await setupEntities(); }); @@ -66,112 +62,86 @@ describe('RestartUserLoginMigrationUc', () => { const migrationBeforeRestart: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ closedAt: new Date(2023, 5), }); - const migrationAfterRestart: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId(); + const migrationAfterRestart: UserLoginMigrationDO = new UserLoginMigrationDO({ + ...migrationBeforeRestart, + closedAt: undefined, + }); const user: User = userFactory.buildWithId(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migrationBeforeRestart); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); userLoginMigrationService.restartMigration.mockResolvedValueOnce(migrationAfterRestart); - return { user, school, migrationAfterRestart }; + return { + user, + migrationBeforeRestart, + migrationAfterRestart, + }; }; it('should check permission', async () => { - const { user, school } = setup(); + const { user, migrationBeforeRestart } = setup(); - await uc.restartMigration('userId', 'schoolId'); + await uc.restartMigration(user.id, user.school.id); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, - school, + migrationBeforeRestart, AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) ); }); - it('should call the service to restart a migration', async () => { - setup(); - - await uc.restartMigration('userId', 'schoolId'); - - expect(userLoginMigrationService.restartMigration).toHaveBeenCalledWith('schoolId'); - }); - - it('should return a UserLoginMigration', async () => { - const { migrationAfterRestart } = setup(); - - const result: UserLoginMigrationDO = await uc.restartMigration('userId', 'schoolId'); + it('should restart the migration', async () => { + const { user, migrationBeforeRestart } = setup(); - expect(result).toEqual(migrationAfterRestart); - }); - }); + await uc.restartMigration(user.id, user.school.id); - describe('when an admin restarts a running migration', () => { - const setup = () => { - const runningMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId(); - - const user: User = userFactory.buildWithId(); - - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); - userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(runningMigration); - - return { user, school, runningMigration }; - }; - - it('should check permission', async () => { - const { user, school } = setup(); - - await uc.restartMigration('userId', 'schoolId'); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith( - user, - school, - AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) - ); + expect(userLoginMigrationService.restartMigration).toHaveBeenCalledWith(migrationBeforeRestart); }); - it('should not call the service to restart a migration', async () => { - setup(); + it('should unmark outdated users', async () => { + const { user, migrationAfterRestart } = setup(); - await uc.restartMigration('userId', 'schoolId'); + await uc.restartMigration(user.id, user.school.id); - expect(userLoginMigrationService.restartMigration).not.toHaveBeenCalled(); + expect(schoolMigrationService.unmarkOutdatedUsers).toHaveBeenCalledWith(migrationAfterRestart); }); it('should return a UserLoginMigration', async () => { - const { runningMigration } = setup(); + const { user, migrationAfterRestart } = setup(); - const result: UserLoginMigrationDO = await uc.restartMigration('userId', 'schoolId'); + const result: UserLoginMigrationDO = await uc.restartMigration(user.id, user.school.id); - expect(result).toEqual(runningMigration); + expect(result).toEqual(migrationAfterRestart); }); }); describe('when the user does not have enough permission', () => { const setup = () => { const user: User = userFactory.buildWithId(); + const migrationBeforeRestart: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + closedAt: new Date(2023, 5), + }); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const error = new ForbiddenException(); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migrationBeforeRestart); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); authorizationService.checkPermission.mockImplementationOnce(() => { - throw new ForbiddenException(); + throw error; }); + + return { + user, + error, + }; }; it('should throw an exception', async () => { - setup(); + const { user, error } = setup(); - const func = async () => uc.restartMigration('userId', 'schoolId'); - - await expect(func).rejects.toThrow(ForbiddenException); + await expect(uc.restartMigration(user.id, user.school.id)).rejects.toThrow(error); }); }); @@ -179,47 +149,19 @@ describe('RestartUserLoginMigrationUc', () => { const setup = () => { const user: User = userFactory.buildWithId(); - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); - }; - it('should throw a UserLoginMigrationNotFoundLoggableException', async () => { - setup(); - - const func = async () => uc.restartMigration('userId', 'schoolId'); - - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); - }); - }); - - describe('when the grace period for restarting a migration has expired', () => { - const setup = () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 6)); - - const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - closedAt: new Date(2023, 5), - finishedAt: new Date(2023, 5), - }); - - const user: User = userFactory.buildWithId(); - - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); - userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migration); + return { + user, + }; }; - it('should throw a UserLoginMigrationGracePeriodExpiredLoggableException', async () => { - setup(); - - const func = async () => uc.restartMigration('userId', 'schoolId'); + it('should throw a UserLoginMigrationNotFoundLoggableException', async () => { + const { user } = setup(); - await expect(func).rejects.toThrow(UserLoginMigrationGracePeriodExpiredLoggableException); + await expect(uc.restartMigration(user.id, user.school.id)).rejects.toThrow( + UserLoginMigrationNotFoundLoggableException + ); }); }); }); diff --git a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts index 997276c3661..60be2ca956b 100644 --- a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts @@ -1,54 +1,45 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; +import { Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../error'; -import { UserLoginMigrationStartLoggable } from '../loggable'; -import { UserLoginMigrationService } from '../service'; +import { UserLoginMigrationNotFoundLoggableException, UserLoginMigrationStartLoggable } from '../loggable'; +import { SchoolMigrationService, UserLoginMigrationService } from '../service'; @Injectable() export class RestartUserLoginMigrationUc { constructor( private readonly userLoginMigrationService: UserLoginMigrationService, private readonly authorizationService: AuthorizationService, - private readonly schoolService: LegacySchoolService, + private readonly schoolMigrationService: SchoolMigrationService, private readonly logger: Logger ) { this.logger.setContext(RestartUserLoginMigrationUc.name); } - async restartMigration(userId: string, schoolId: string): Promise { - await this.checkPermission(userId, schoolId); - - let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + public async restartMigration(userId: string, schoolId: string): Promise { + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( schoolId ); if (!userLoginMigration) { throw new UserLoginMigrationNotFoundLoggableException(schoolId); - } else if (userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime()) { - throw new UserLoginMigrationGracePeriodExpiredLoggableException( - userLoginMigration.id as string, - userLoginMigration.finishedAt - ); - } else if (userLoginMigration.closedAt) { - userLoginMigration = await this.userLoginMigrationService.restartMigration(schoolId); - - this.logger.info(new UserLoginMigrationStartLoggable(userId, schoolId)); } - return userLoginMigration; - } - - async checkPermission(userId: string, schoolId: string): Promise { const user: User = await this.authorizationService.getUserWithPermissions(userId); - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + this.authorizationService.checkPermission( + user, + userLoginMigration, + AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]) + ); + + const updatedUserLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.restartMigration( + userLoginMigration + ); + + await this.schoolMigrationService.unmarkOutdatedUsers(updatedUserLoginMigration); + + this.logger.info(new UserLoginMigrationStartLoggable(userId, updatedUserLoginMigration.id)); - const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.USER_LOGIN_MIGRATION_ADMIN]); - this.authorizationService.checkPermission(user, school, context); + return updatedUserLoginMigration; } } diff --git a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.spec.ts index 3f0c03c07ff..b6d3c0210f2 100644 --- a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.spec.ts @@ -1,16 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; +import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { legacySchoolDoFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { SchoolNumberMissingLoggableException, UserLoginMigrationAlreadyClosedLoggableException } from '../error'; +import { SchoolNumberMissingLoggableException, UserLoginMigrationAlreadyClosedLoggableException } from '../loggable'; import { UserLoginMigrationService } from '../service'; import { StartUserLoginMigrationUc } from './start-user-login-migration.uc'; -describe('StartUserLoginMigrationUc', () => { +describe(StartUserLoginMigrationUc.name, () => { let module: TestingModule; let uc: StartUserLoginMigrationUc; diff --git a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts index 3dd84cc6ef5..1bf47635d83 100644 --- a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts @@ -1,10 +1,13 @@ -import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; -import { Logger } from '@src/core/logger'; import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { SchoolNumberMissingLoggableException, UserLoginMigrationAlreadyClosedLoggableException } from '../error'; -import { UserLoginMigrationStartLoggable } from '../loggable'; +import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; +import { EntityId, LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { + SchoolNumberMissingLoggableException, + UserLoginMigrationAlreadyClosedLoggableException, + UserLoginMigrationStartLoggable, +} from '../loggable'; import { UserLoginMigrationService } from '../service'; @Injectable() @@ -18,7 +21,7 @@ export class StartUserLoginMigrationUc { this.logger.setContext(StartUserLoginMigrationUc.name); } - async startMigration(userId: string, schoolId: string): Promise { + async startMigration(userId: EntityId, schoolId: EntityId): Promise { await this.checkPreconditions(userId, schoolId); let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( @@ -28,18 +31,15 @@ export class StartUserLoginMigrationUc { if (!userLoginMigration) { userLoginMigration = await this.userLoginMigrationService.startMigration(schoolId); - this.logger.info(new UserLoginMigrationStartLoggable(userId, userLoginMigration.id as string)); + this.logger.info(new UserLoginMigrationStartLoggable(userId, userLoginMigration.id)); } else if (userLoginMigration.closedAt) { - throw new UserLoginMigrationAlreadyClosedLoggableException( - userLoginMigration.id as string, - userLoginMigration.closedAt - ); + throw new UserLoginMigrationAlreadyClosedLoggableException(userLoginMigration.closedAt, userLoginMigration.id); } return userLoginMigration; } - async checkPreconditions(userId: string, schoolId: string): Promise { + private async checkPreconditions(userId: string, schoolId: string): Promise { const user: User = await this.authorizationService.getUserWithPermissions(userId); const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); diff --git a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts index b0ff1f67e54..3416250a67e 100644 --- a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts @@ -1,20 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; +import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { legacySchoolDoFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { - UserLoginMigrationAlreadyClosedLoggableException, - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../error'; +import { ObjectId } from 'bson'; +import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { UserLoginMigrationService } from '../service'; import { ToggleUserLoginMigrationUc } from './toggle-user-login-migration.uc'; -describe('ToggleUserLoginMigrationUc', () => { +describe(ToggleUserLoginMigrationUc.name, () => { let module: TestingModule; let uc: ToggleUserLoginMigrationUc; @@ -78,13 +75,13 @@ describe('ToggleUserLoginMigrationUc', () => { userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migrationBeforeMandatory); userLoginMigrationService.setMigrationMandatory.mockResolvedValueOnce(migrationAfterMandatory); - return { user, school, migrationAfterMandatory }; + return { user, school, migrationAfterMandatory, migrationBeforeMandatory }; }; it('should check permission', async () => { const { user, school } = setup(); - await uc.setMigrationMandatory('userId', 'schoolId', true); + await uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), true); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, @@ -94,17 +91,21 @@ describe('ToggleUserLoginMigrationUc', () => { }); it('should call the service to set a migration mandatory', async () => { - setup(); + const { migrationBeforeMandatory } = setup(); - await uc.setMigrationMandatory('userId', 'schoolId', true); + await uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), true); - expect(userLoginMigrationService.setMigrationMandatory).toHaveBeenCalledWith('schoolId', true); + expect(userLoginMigrationService.setMigrationMandatory).toHaveBeenCalledWith(migrationBeforeMandatory, true); }); it('should return a UserLoginMigration', async () => { const { migrationAfterMandatory } = setup(); - const result: UserLoginMigrationDO = await uc.setMigrationMandatory('userId', 'schoolId', true); + const result: UserLoginMigrationDO = await uc.setMigrationMandatory( + new ObjectId().toHexString(), + new ObjectId().toHexString(), + true + ); expect(result).toEqual(migrationAfterMandatory); }); @@ -126,13 +127,13 @@ describe('ToggleUserLoginMigrationUc', () => { userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migrationBeforeOptional); userLoginMigrationService.setMigrationMandatory.mockResolvedValueOnce(migrationAfterOptional); - return { user, school, migrationAfterOptional }; + return { user, school, migrationAfterOptional, migrationBeforeOptional }; }; it('should check permission', async () => { const { user, school } = setup(); - await uc.setMigrationMandatory('userId', 'schoolId', false); + await uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), false); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, @@ -142,17 +143,21 @@ describe('ToggleUserLoginMigrationUc', () => { }); it('should call the service to set a migration optional', async () => { - setup(); + const { migrationBeforeOptional } = setup(); - await uc.setMigrationMandatory('userId', 'schoolId', false); + await uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), false); - expect(userLoginMigrationService.setMigrationMandatory).toHaveBeenCalledWith('schoolId', false); + expect(userLoginMigrationService.setMigrationMandatory).toHaveBeenCalledWith(migrationBeforeOptional, false); }); it('should return a UserLoginMigration', async () => { const { migrationAfterOptional } = setup(); - const result: UserLoginMigrationDO = await uc.setMigrationMandatory('userId', 'schoolId', false); + const result: UserLoginMigrationDO = await uc.setMigrationMandatory( + new ObjectId().toHexString(), + new ObjectId().toHexString(), + false + ); expect(result).toEqual(migrationAfterOptional); }); @@ -174,9 +179,9 @@ describe('ToggleUserLoginMigrationUc', () => { it('should throw an exception', async () => { setup(); - const func = async () => uc.setMigrationMandatory('userId', 'schoolId', true); - - await expect(func).rejects.toThrow(ForbiddenException); + await expect( + uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), true) + ).rejects.toThrow(ForbiddenException); }); }); @@ -194,61 +199,9 @@ describe('ToggleUserLoginMigrationUc', () => { it('should throw a UserLoginMigrationNotFoundLoggableException', async () => { setup(); - const func = async () => uc.setMigrationMandatory('userId', 'schoolId', true); - - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); - }); - }); - - describe('when the grace period for restarting a migration has expired', () => { - const setup = () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 6)); - - const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - closedAt: new Date(2023, 5), - finishedAt: new Date(2023, 5), - }); - - const user: User = userFactory.buildWithId(); - - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); - userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migration); - }; - - it('should throw a UserLoginMigrationGracePeriodExpiredLoggableException', async () => { - setup(); - - const func = async () => uc.setMigrationMandatory('userId', 'schoolId', true); - - await expect(func).rejects.toThrow(UserLoginMigrationGracePeriodExpiredLoggableException); - }); - }); - - describe('when migration is closed', () => { - const setup = () => { - const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ - closedAt: new Date(2023, 5), - }); - - const user: User = userFactory.buildWithId(); - - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolService.getSchoolById.mockResolvedValueOnce(school); - userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migration); - }; - - it('should throw a UserLoginMigrationAlreadyClosedLoggableException', async () => { - setup(); - - const func = async () => uc.setMigrationMandatory('userId', 'schoolId', true); - - await expect(func).rejects.toThrow(UserLoginMigrationAlreadyClosedLoggableException); + await expect( + uc.setMigrationMandatory(new ObjectId().toHexString(), new ObjectId().toHexString(), true) + ).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); }); }); }); diff --git a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.ts index 45de7b6e1e3..cb964ff7fa6 100644 --- a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.ts @@ -1,14 +1,9 @@ -import { Injectable } from '@nestjs/common'; -import { Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; -import { Logger } from '@src/core/logger'; import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { - UserLoginMigrationAlreadyClosedLoggableException, - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../error'; -import { UserLoginMigrationMandatoryLoggable } from '../loggable'; +import { Injectable } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { UserLoginMigrationMandatoryLoggable, UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { UserLoginMigrationService } from '../service'; @Injectable() @@ -20,7 +15,7 @@ export class ToggleUserLoginMigrationUc { private readonly logger: Logger ) {} - async setMigrationMandatory(userId: string, schoolId: string, mandatory: boolean): Promise { + async setMigrationMandatory(userId: EntityId, schoolId: EntityId, mandatory: boolean): Promise { await this.checkPermission(userId, schoolId); let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( @@ -29,26 +24,16 @@ export class ToggleUserLoginMigrationUc { if (!userLoginMigration) { throw new UserLoginMigrationNotFoundLoggableException(schoolId); - } else if (userLoginMigration.finishedAt && Date.now() >= userLoginMigration.finishedAt.getTime()) { - throw new UserLoginMigrationGracePeriodExpiredLoggableException( - userLoginMigration.id as string, - userLoginMigration.finishedAt - ); - } else if (userLoginMigration.closedAt) { - throw new UserLoginMigrationAlreadyClosedLoggableException( - userLoginMigration.id as string, - userLoginMigration.closedAt - ); - } else { - userLoginMigration = await this.userLoginMigrationService.setMigrationMandatory(schoolId, mandatory); - - this.logger.debug(new UserLoginMigrationMandatoryLoggable(userId, userLoginMigration.id as string, mandatory)); } + userLoginMigration = await this.userLoginMigrationService.setMigrationMandatory(userLoginMigration, mandatory); + + this.logger.debug(new UserLoginMigrationMandatoryLoggable(userId, userLoginMigration.id, mandatory)); + return userLoginMigration; } - async checkPermission(userId: string, schoolId: string): Promise { + private async checkPermission(userId: string, schoolId: string): Promise { const user: User = await this.authorizationService.getUserWithPermissions(userId); const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); 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 f5f710ae990..4179505d009 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -1,8 +1,20 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthenticationService } from '@modules/authentication'; +import { Action, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; +import { OAuthTokenDto, OAuthService } from '@modules/oauth'; +import { + ProvisioningService, + ExternalSchoolDto, + ExternalUserDto, + OauthDataDto, + ProvisioningSystemDto, +} from '@modules/provisioning'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Page, Permission, LegacySchoolDo, SystemEntity, User, UserLoginMigrationDO } from '@shared/domain'; +import { LegacySchoolDo, Page, Permission, SystemEntity, User, UserLoginMigrationDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, @@ -11,22 +23,12 @@ import { userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { Action, AuthorizationService } from '@modules/authorization'; -import { OAuthTokenDto } from '@modules/oauth'; -import { OAuthService } from '@modules/oauth/service/oauth.service'; -import { ProvisioningService } from '@modules/provisioning'; -import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { Oauth2MigrationParams } from '../controller/dto/oauth2-migration.params'; -import { OAuthMigrationError, SchoolMigrationError, UserLoginMigrationError } from '../error'; -import { PageTypes } from '../interface/page-types.enum'; +import { Logger } from '@src/core/logger'; +import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException } from '../loggable'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; -import { MigrationDto, PageContentDto } from '../service/dto'; import { UserLoginMigrationUc } from './user-login-migration.uc'; -describe('UserLoginMigrationUc', () => { +describe(UserLoginMigrationUc.name, () => { let module: TestingModule; let uc: UserLoginMigrationUc; @@ -37,7 +39,6 @@ describe('UserLoginMigrationUc', () => { let userMigrationService: DeepMocked; let authenticationService: DeepMocked; let authorizationService: DeepMocked; - let logger: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -78,8 +79,8 @@ describe('UserLoginMigrationUc', () => { useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, ], }).compile(); @@ -93,7 +94,6 @@ describe('UserLoginMigrationUc', () => { userMigrationService = module.get(UserMigrationService); authenticationService = module.get(AuthenticationService); authorizationService = module.get(AuthorizationService); - logger = module.get(LegacyLogger); }); afterAll(async () => { @@ -104,38 +104,10 @@ describe('UserLoginMigrationUc', () => { jest.clearAllMocks(); }); - describe('getPageContent is called', () => { - describe('when it should get page-content', () => { - const setup = () => { - const dto: PageContentDto = { - proceedButtonUrl: 'proceed', - cancelButtonUrl: 'cancel', - }; - - userMigrationService.getPageContent.mockResolvedValue(dto); - - return { dto }; - }; - - it('should return a response', async () => { - const { dto } = setup(); - - const testResp: PageContentDto = await uc.getPageContent( - PageTypes.START_FROM_TARGET_SYSTEM, - 'source', - 'target' - ); - - expect(testResp.proceedButtonUrl).toEqual(dto.proceedButtonUrl); - expect(testResp.cancelButtonUrl).toEqual(dto.cancelButtonUrl); - }); - }); - }); - describe('getMigrations', () => { describe('when searching for a users migration', () => { const setup = () => { - const userId = 'userId'; + const userId = new ObjectId().toHexString(); const migrations: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ schoolId: 'schoolId', @@ -143,12 +115,12 @@ describe('UserLoginMigrationUc', () => { startedAt: new Date(), }); - userLoginMigrationService.findMigrationByUser.mockResolvedValue(migrations); + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(migrations); return { userId, migrations }; }; - it('should return a response', async () => { + it('should return a page response with data', async () => { const { userId, migrations } = setup(); const result: Page = await uc.getMigrations(userId, { userId }); @@ -162,14 +134,14 @@ describe('UserLoginMigrationUc', () => { describe('when a user has no migration available', () => { const setup = () => { - const userId = 'userId'; + const userId = new ObjectId().toHexString(); - userLoginMigrationService.findMigrationByUser.mockResolvedValue(null); + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(null); return { userId }; }; - it('should return a response', async () => { + it('should return a page response without data', async () => { const { userId } = setup(); const result: Page = await uc.getMigrations(userId, { userId }); @@ -183,12 +155,12 @@ describe('UserLoginMigrationUc', () => { describe('when searching for other users migrations', () => { const setup = () => { - const userId = 'userId'; + const userId = new ObjectId().toHexString(); return { userId }; }; - it('should return a response', async () => { + it('should throw a forbidden exception', async () => { const { userId } = setup(); const func = async () => uc.getMigrations(userId, { userId: 'otherUserId' }); @@ -203,7 +175,7 @@ describe('UserLoginMigrationUc', () => { describe('findUserLoginMigrationBySchool', () => { describe('when searching for an existing user login migration', () => { const setup = () => { - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); const migration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ schoolId, @@ -212,8 +184,8 @@ describe('UserLoginMigrationUc', () => { }); const user: User = userFactory.buildWithId(); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(migration); - authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migration); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { user, schoolId, migration }; }; @@ -240,12 +212,12 @@ describe('UserLoginMigrationUc', () => { describe('when a user login migration does not exist', () => { const setup = () => { - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); const user: User = userFactory.buildWithId(); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); - authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { user, schoolId }; }; @@ -261,7 +233,7 @@ describe('UserLoginMigrationUc', () => { describe('when the authorization fails', () => { const setup = () => { - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); const user: User = userFactory.buildWithId(); @@ -273,8 +245,8 @@ describe('UserLoginMigrationUc', () => { const error = new Error('Authorization failed'); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(migration); - authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(migration); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); authorizationService.checkPermission.mockImplementation(() => { throw error; }); @@ -293,41 +265,16 @@ describe('UserLoginMigrationUc', () => { }); describe('migrate', () => { - describe('when user migrates the from one to another system', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); - - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ - systems: [sourceSystem.id], - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'oldSchoolExternalId', - }); - const externalUserId = 'externalUserId'; - + describe('when user migrates from one to another system', () => { + const setup = () => { const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), externalUser: new ExternalUserDto({ - externalId: externalUserId, + externalId: 'externalUserId', }), - externalSchool: new ExternalSchoolDto({ - externalId: 'externalId', - officialSchoolNumber: 'officialSchoolNumber', - name: 'schoolName', - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', }); const tokenDto: OAuthTokenDto = new OAuthTokenDto({ @@ -336,85 +283,62 @@ describe('UserLoginMigrationUc', () => { accessToken: 'accessToken', }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockResolvedValue(schoolDO); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - const message1 = `MIGRATION (userId: currentUserId): Migrates to targetSystem with id ${oauthData.system.systemId}`; - - const message2 = `MIGRATION (userId: currentUserId): Provisioning data received from targetSystem (${ - oauthData.system.systemId ?? 'N/A' - } with data: - { - "officialSchoolNumber": ${oauthData.externalSchool?.officialSchoolNumber ?? 'N/A'}, - "externalSchoolId": ${oauthData.externalSchool?.externalId ?? ''} - "externalUserId": ${oauthData.externalUser.externalId}, - })`; + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: undefined, + finishedAt: undefined, + }); - const message3 = `MIGRATION (userId: currentUserId): Found school with officialSchoolNumber (${ - oauthData.externalSchool?.officialSchoolNumber ?? '' - })`; + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); + oAuthService.authenticateUser.mockResolvedValueOnce(tokenDto); + provisioningService.getData.mockResolvedValueOnce(oauthData); return { - query, - userMigrationDto, oauthData, tokenDto, - message1, - message2, - message3, }; }; - it('should call authenticate User', async () => { - const { query } = setupMigration(); + it('should authenticate the user with oauth2', async () => { + setup(); - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - expect(oAuthService.authenticateUser).toHaveBeenCalledWith(query.systemId, query.redirectUri, query.code); + expect(oAuthService.authenticateUser).toHaveBeenCalledWith('systemId', 'redirectUri', 'code'); }); - it('should call get provisioning data', async () => { - const { query, tokenDto } = setupMigration(); + it('should fetch the provisioning data for the user', async () => { + const { tokenDto } = setup(); - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - expect(provisioningService.getData).toHaveBeenCalledWith( - query.systemId, - tokenDto.idToken, - tokenDto.accessToken - ); + expect(provisioningService.getData).toHaveBeenCalledWith('systemId', tokenDto.idToken, tokenDto.accessToken); }); - it('should call migrate user successfully', async () => { - const { query, oauthData } = setupMigration(); + it('should migrate the user successfully', async () => { + const { oauthData } = setup(); - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); expect(userMigrationService.migrateUser).toHaveBeenCalledWith( 'currentUserId', oauthData.externalUser.externalId, - query.systemId + 'systemId' ); }); it('should remove the jwt from the whitelist', async () => { - const { query } = setupMigration(); + setup(); - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); expect(authenticationService.removeJwtFromWhitelist).toHaveBeenCalledWith('jwt'); }); }); - describe('when migration of user failed', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - + describe('when external school and official school number is defined and school has to be migrated', () => { + const setup = () => { const sourceSystem: SystemEntity = systemFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); @@ -425,15 +349,13 @@ describe('UserLoginMigrationUc', () => { externalId: 'oldSchoolExternalId', }); - const externalUserId = 'externalUserId'; - const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), externalUser: new ExternalUserDto({ - externalId: externalUserId, + externalId: 'externalUserId', }), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', @@ -441,63 +363,64 @@ describe('UserLoginMigrationUc', () => { name: 'schoolName', }), }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/error', - }); - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ idToken: 'idToken', refreshToken: 'refreshToken', accessToken: 'accessToken', }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockResolvedValue(schoolDO); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: undefined, + finishedAt: undefined, + }); + + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); + oAuthService.authenticateUser.mockResolvedValueOnce(tokenDto); + provisioningService.getData.mockResolvedValueOnce(oauthData); + schoolMigrationService.getSchoolForMigration.mockResolvedValueOnce(schoolDO); return { - query, - userMigrationDto, + schoolDO, + oauthData, }; }; - it('should throw UserloginMigrationError', async () => { - const { query } = setupMigration(); + it('should get the school that should be migrated', async () => { + const { oauthData } = setup(); - const func = () => uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - await expect(func).rejects.toThrow(new UserLoginMigrationError()); + expect(schoolMigrationService.getSchoolForMigration).toHaveBeenCalledWith( + 'currentUserId', + oauthData.externalSchool?.externalId, + oauthData.externalSchool?.officialSchoolNumber + ); }); - }); - describe('when schoolnumbers mismatch', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; + it('should migrate the school', async () => { + const { oauthData, schoolDO } = setup(); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ - systems: [sourceSystem.id], - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'oldSchoolExternalId', - }); - - const externalUserId = 'externalUserId'; + expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( + schoolDO, + oauthData.externalSchool?.externalId, + 'systemId' + ); + }); + }); + describe('when external school and official school number is defined and school is already migrated', () => { + const setup = () => { const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), externalUser: new ExternalUserDto({ - externalId: externalUserId, + externalId: 'externalUserId', }), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', @@ -506,75 +429,48 @@ describe('UserLoginMigrationUc', () => { }), }); - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/error', - }); - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ idToken: 'idToken', refreshToken: 'refreshToken', accessToken: 'accessToken', }); - const error: OAuthMigrationError = new OAuthMigrationError( - 'Current users school is not the same as school found by official school number from target migration system', - 'ext_official_school_number_mismatch', - schoolDO.officialSchoolNumber, - oauthData.externalSchool?.officialSchoolNumber - ); + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: undefined, + finishedAt: undefined, + }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockRejectedValue(error); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); + oAuthService.authenticateUser.mockResolvedValueOnce(tokenDto); + provisioningService.getData.mockResolvedValueOnce(oauthData); + schoolMigrationService.getSchoolForMigration.mockResolvedValueOnce(null); return { - query, - userMigrationDto, - error, + oauthData, }; }; - it('should throw SchoolMigrationError', async () => { - const { query, error } = setupMigration(); + it('should not migrate the school', async () => { + setup(); - const func = () => uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - await expect(func).rejects.toThrow( - new SchoolMigrationError({ - sourceSchoolNumber: error.officialSchoolNumberFromSource, - targetSchoolNumber: error.officialSchoolNumberFromTarget, - }) - ); + expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); }); }); - describe('when school is missing', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - - const externalUserId = 'externalUserId'; - + describe('when external school is not defined', () => { + const setup = () => { const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), externalUser: new ExternalUserDto({ - externalId: externalUserId, + externalId: 'externalUserId', }), - externalSchool: new ExternalSchoolDto({ - externalId: 'externalId', - officialSchoolNumber: 'officialSchoolNumber', - name: 'schoolName', - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/error', }); const tokenDto: OAuthTokenDto = new OAuthTokenDto({ @@ -583,250 +479,129 @@ describe('UserLoginMigrationUc', () => { accessToken: 'accessToken', }); - const error: OAuthMigrationError = new OAuthMigrationError( - 'Official school number from target migration system is missing', - 'ext_official_school_number_missing' - ); - - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockRejectedValue(error); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: undefined, + finishedAt: undefined, + }); - return { - query, - userMigrationDto, - }; + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); + oAuthService.authenticateUser.mockResolvedValueOnce(tokenDto); + provisioningService.getData.mockResolvedValueOnce(oauthData); + schoolMigrationService.getSchoolForMigration.mockResolvedValueOnce(null); }; - it('should throw SchoolMigrationError', async () => { - const { query } = setupMigration(); + it('should try to migrate the school', async () => { + setup(); - const func = () => uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + await uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri'); - await expect(func).rejects.toThrow(new SchoolMigrationError()); + expect(schoolMigrationService.getSchoolForMigration).not.toHaveBeenCalled(); + expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); }); }); - describe('when external school and official school number is defined and school has to be migrated', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); - - const schoolDO: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ - systems: [sourceSystem.id], - officialSchoolNumber: 'officialSchoolNumber', - externalId: 'oldSchoolExternalId', - }); - - const externalUserId = 'externalUserId'; - + describe('when a external school is defined, but has no official school number', () => { + const setup = () => { const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), externalUser: new ExternalUserDto({ - externalId: externalUserId, + externalId: 'externalUserId', }), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', - officialSchoolNumber: 'officialSchoolNumber', name: 'schoolName', }), }); - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/dashboard', - }); - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ idToken: 'idToken', refreshToken: 'refreshToken', accessToken: 'accessToken', }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockResolvedValue(schoolDO); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - const text = `Successfully migrated school (${schoolDO.name} - (${schoolDO.id ?? 'N/A'}) to targetSystem ${ - query.systemId ?? 'N/A' - } which has the externalSchoolId ${oauthData.externalSchool?.externalId ?? 'N/A'}`; + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: undefined, + finishedAt: undefined, + }); - const message = `MIGRATION (userId: currentUserId): ${text ?? ''}`; + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); + oAuthService.authenticateUser.mockResolvedValueOnce(tokenDto); + provisioningService.getData.mockResolvedValueOnce(oauthData); + schoolMigrationService.getSchoolForMigration.mockResolvedValueOnce(null); return { - query, - userMigrationDto, - schoolDO, oauthData, - message, }; }; - it('should call schoolToMigrate', async () => { - const { oauthData, query } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + it('should throw an error', async () => { + const { oauthData } = setup(); - expect(schoolMigrationService.schoolToMigrate).toHaveBeenCalledWith( - 'currentUserId', - oauthData.externalSchool?.externalId, - oauthData.externalSchool?.officialSchoolNumber + await expect(uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri')).rejects.toThrow( + new ExternalSchoolNumberMissingLoggableException(oauthData.externalSchool?.externalId as string) ); }); + }); - it('should call migrateSchool', async () => { - const { oauthData, query, schoolDO } = setupMigration(); + describe('when no user login migration is running', () => { + const setup = () => { + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(null); + }; - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + it('should throw an error', async () => { + setup(); - expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( - oauthData.externalSchool?.externalId, - schoolDO, - 'systemId' + await expect(uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri')).rejects.toThrow( + InvalidUserLoginMigrationLoggableException ); }); - - it('should log migration information', async () => { - const { query, message } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); - - expect(logger.debug).toHaveBeenCalledWith(message); - }); }); - describe('when external school and official school number is defined and school is already migrated', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: new ExternalSchoolDto({ - externalId: 'externalId', - officialSchoolNumber: 'officialSchoolNumber', - name: 'schoolName', - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/dashboard', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', + describe('when the user login migration is closed', () => { + const setup = () => { + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'systemId', + closedAt: new Date(), }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockResolvedValue(null); - schoolMigrationService.schoolToMigrate.mockResolvedValueOnce(null); - schoolMigrationService.migrateSchool.mockResolvedValue(); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - const message = `MIGRATION (userId: currentUserId): Found school with officialSchoolNumber (officialSchoolNumber)`; - - return { - query, - userMigrationDto, - oauthData, - message, - }; + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); }; - it('should not call migrateSchool', async () => { - const { query } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); - - expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); - }); - - it('should log migration information', async () => { - const { query, message } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + it('should throw an error', async () => { + setup(); - expect(logger.debug).toHaveBeenCalledWith(message); + await expect(uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri')).rejects.toThrow( + InvalidUserLoginMigrationLoggableException + ); }); }); - describe('when external school is not defined', () => { - const setupMigration = () => { - const query: Oauth2MigrationParams = new Oauth2MigrationParams(); - query.code = 'code'; - query.systemId = 'systemId'; - query.redirectUri = 'redirectUri'; - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/dashboard', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', + describe('when trying to migrate to the wrong system', () => { + const setup = () => { + const userLoginMigration = userLoginMigrationDOFactory.build({ + id: new ObjectId().toHexString(), + targetSystemId: 'wrongSystemId', + closedAt: undefined, + finishedAt: undefined, }); - oAuthService.authenticateUser.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - schoolMigrationService.schoolToMigrate.mockResolvedValueOnce(null); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - const message = `Provisioning data received from targetSystem (${oauthData.system.systemId ?? 'N/A'} with data: - { - "officialSchoolNumber": ${oauthData.externalSchool?.officialSchoolNumber ?? 'N/A'}, - "externalSchoolId": ${oauthData.externalSchool?.externalId ?? ''} - "externalUserId": ${oauthData.externalUser.externalId}, - })`; - - return { - query, - userMigrationDto, - message, - }; + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(userLoginMigration); }; - it('should not call schoolToMigrate', async () => { - const { query } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query.systemId, query.code, query.redirectUri); + it('should throw an error', async () => { + setup(); - expect(schoolMigrationService.schoolToMigrate).not.toHaveBeenCalled(); + await expect(uc.migrate('jwt', 'currentUserId', 'systemId', 'code', 'redirectUri')).rejects.toThrow( + InvalidUserLoginMigrationLoggableException + ); }); }); }); diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts index a637afe01f6..8314cbfd455 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts @@ -1,17 +1,19 @@ +import { AuthenticationService } from '@modules/authentication'; +import { Action, AuthorizationService } from '@modules/authorization'; +import { OAuthTokenDto, OAuthService } from '@modules/oauth'; +import { ProvisioningService, OauthDataDto } from '@modules/provisioning'; import { ForbiddenException, Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { EntityId, Page, Permission, LegacySchoolDo, User, UserLoginMigrationDO } from '@shared/domain'; -import { LegacyLogger } from '@src/core/logger'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { Action, AuthorizationService } from '@modules/authorization'; -import { OAuthTokenDto } from '@modules/oauth'; -import { OAuthService } from '@modules/oauth/service/oauth.service'; -import { ProvisioningService } from '@modules/provisioning'; -import { OauthDataDto } from '@modules/provisioning/dto'; -import { OAuthMigrationError, SchoolMigrationError, UserLoginMigrationError } from '../error'; -import { PageTypes } from '../interface/page-types.enum'; +import { EntityId, LegacySchoolDo, Page, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { + ExternalSchoolNumberMissingLoggableException, + InvalidUserLoginMigrationLoggableException, + SchoolMigrationSuccessfulLoggable, + UserMigrationStartedLoggable, + UserMigrationSuccessfulLoggable, +} from '../loggable'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; -import { MigrationDto, PageContentDto } from '../service/dto'; import { UserLoginMigrationQuery } from './dto'; @Injectable() @@ -24,19 +26,9 @@ export class UserLoginMigrationUc { private readonly schoolMigrationService: SchoolMigrationService, private readonly authenticationService: AuthenticationService, private readonly authorizationService: AuthorizationService, - private readonly logger: LegacyLogger + private readonly logger: Logger ) {} - async getPageContent(pageType: PageTypes, sourceSystem: string, targetSystem: string): Promise { - const content: PageContentDto = await this.userMigrationService.getPageContent( - pageType, - sourceSystem, - targetSystem - ); - - return content; - } - async getMigrations(userId: EntityId, query: UserLoginMigrationQuery): Promise> { let page = new Page([], 0); @@ -77,14 +69,22 @@ export class UserLoginMigrationUc { async migrate( userJwt: string, - currentUserId: string, + currentUserId: EntityId, targetSystemId: EntityId, code: string, redirectUri: string ): Promise { + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationByUser( + currentUserId + ); + + if (!userLoginMigration || userLoginMigration.closedAt || userLoginMigration.targetSystemId !== targetSystemId) { + throw new InvalidUserLoginMigrationLoggableException(currentUserId, targetSystemId); + } + const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(targetSystemId, redirectUri, code); - this.logMigrationInformation(currentUserId, `Migrates to targetSystem with id ${targetSystemId}`); + this.logger.debug(new UserMigrationStartedLoggable(currentUserId, userLoginMigration)); const data: OauthDataDto = await this.provisioningService.getData( targetSystemId, @@ -92,87 +92,32 @@ export class UserLoginMigrationUc { tokenDto.accessToken ); - this.logMigrationInformation(currentUserId, undefined, data, targetSystemId); - if (data.externalSchool) { - let schoolToMigrate: LegacySchoolDo | null; - // TODO: N21-820 after fully switching to the new client login flow, try/catch will be obsolete and schoolToMigrate should throw correct errors - try { - schoolToMigrate = await this.schoolMigrationService.schoolToMigrate( - currentUserId, - data.externalSchool.externalId, - data.externalSchool.officialSchoolNumber - ); - } catch (error: unknown) { - let details: Record | undefined; - - if ( - error instanceof OAuthMigrationError && - error.officialSchoolNumberFromSource && - error.officialSchoolNumberFromTarget - ) { - details = { - sourceSchoolNumber: error.officialSchoolNumberFromSource, - targetSchoolNumber: error.officialSchoolNumberFromTarget, - }; - } - - throw new SchoolMigrationError(details, error); + if (!data.externalSchool.officialSchoolNumber) { + throw new ExternalSchoolNumberMissingLoggableException(data.externalSchool.externalId); } - this.logMigrationInformation( + const schoolToMigrate: LegacySchoolDo | null = await this.schoolMigrationService.getSchoolForMigration( currentUserId, - `Found school with officialSchoolNumber (${data.externalSchool.officialSchoolNumber ?? ''})` + data.externalSchool.externalId, + data.externalSchool.officialSchoolNumber ); if (schoolToMigrate) { await this.schoolMigrationService.migrateSchool( - data.externalSchool.externalId, schoolToMigrate, + data.externalSchool.externalId, targetSystemId ); - this.logMigrationInformation(currentUserId, undefined, data, data.system.systemId, schoolToMigrate); + this.logger.debug(new SchoolMigrationSuccessfulLoggable(schoolToMigrate, userLoginMigration)); } } - const migrationDto: MigrationDto = await this.userMigrationService.migrateUser( - currentUserId, - data.externalUser.externalId, - targetSystemId - ); + await this.userMigrationService.migrateUser(currentUserId, data.externalUser.externalId, targetSystemId); - // TODO: N21-820 after implementation of new client login flow, redirects will be obsolete and migrate should throw errors directly - if (migrationDto.redirect.includes('migration/error')) { - throw new UserLoginMigrationError({ userId: currentUserId }); - } - - this.logMigrationInformation(currentUserId, `Successfully migrated user and redirects to ${migrationDto.redirect}`); + this.logger.debug(new UserMigrationSuccessfulLoggable(currentUserId, userLoginMigration)); await this.authenticationService.removeJwtFromWhitelist(userJwt); } - - private logMigrationInformation( - userId: string, - text?: string, - oauthData?: OauthDataDto, - targetSystemId?: string, - school?: LegacySchoolDo - ) { - let message = `MIGRATION (userId: ${userId}): ${text ?? ''}`; - if (!school && oauthData) { - message += `Provisioning data received from targetSystem (${targetSystemId ?? 'N/A'} with data: - { - "officialSchoolNumber": ${oauthData.externalSchool?.officialSchoolNumber ?? 'N/A'}, - "externalSchoolId": ${oauthData.externalSchool?.externalId ?? ''} - "externalUserId": ${oauthData.externalUser.externalId}, - })`; - } - if (school && oauthData) { - message += `Successfully migrated school (${school.name} - (${school.id ?? 'N/A'}) to targetSystem ${ - targetSystemId ?? 'N/A' - } which has the externalSchoolId ${oauthData.externalSchool?.externalId ?? 'N/A'}`; - } - this.logger.debug(message); - } } 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 b30a3f40f4d..377689a4f18 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts @@ -1,13 +1,11 @@ -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; +import { LegacySchoolModule } from '@modules/legacy-school'; import { OauthModule } from '@modules/oauth'; import { ProvisioningModule } from '@modules/provisioning'; -import { LegacySchoolModule } from '@modules/legacy-school'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { UserLoginMigrationController } from './controller/user-login-migration.controller'; -import { UserMigrationController } from './controller/user-migration.controller'; -import { PageContentMapper } from './mapper'; import { CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, @@ -33,8 +31,7 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; RestartUserLoginMigrationUc, ToggleUserLoginMigrationUc, CloseUserLoginMigrationUc, - PageContentMapper, ], - controllers: [UserMigrationController, UserLoginMigrationController], + controllers: [UserLoginMigrationController], }) export class UserLoginMigrationApiModule {} diff --git a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts index 32c077e2529..39ff2662cf7 100644 --- a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts @@ -82,8 +82,6 @@ export const customParameterEntityFactory = BaseFactory.define