From e806f1c6e4fd6090471069bef73a10a88b583f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Tue, 14 Nov 2023 08:59:38 +0100 Subject: [PATCH 1/3] N21-1311 Refactor User login migration 1 (#4548) --- .../src/core/logger/types/logging.types.ts | 2 +- .../modules/authentication/errors/index.ts | 1 - .../modules/authentication/loggable/index.ts | 1 + ...ol-in-migration.loggable-exception.spec.ts | 24 + ...school-in-migration.loggable-exception.ts} | 15 +- .../strategy/oauth2.strategy.spec.ts | 18 +- .../strategy/oauth2.strategy.ts | 16 +- .../legacy-school/controller/dto/index.ts | 3 - .../controller/dto/migration.body.ts | 31 - .../controller/dto/migration.response.ts | 40 -- .../controller/dto/school.params.ts | 12 - .../legacy-school.controller.spec.ts | 94 --- .../controller/legacy-school.controller.ts | 61 -- .../legacy-school/legacy-school-api.module.ts | 18 - .../{error => loggable}/index.ts | 0 ...umber-duplicate.loggable-exception.spec.ts | 0 ...ool-number-duplicate.loggable-exception.ts | 0 .../mapper/migration.mapper.spec.ts | 51 -- .../legacy-school/mapper/migration.mapper.ts | 18 - .../school-validation.service.spec.ts | 4 +- .../validation/school-validation.service.ts | 2 +- .../uc/dto/oauth-migration.dto.ts | 19 - .../src/modules/legacy-school/uc/index.ts | 1 - .../legacy-school/uc/legacy-school.uc.spec.ts | 285 -------- .../legacy-school/uc/legacy-school.uc.ts | 105 --- .../src/modules/oauth/controller/dto/index.ts | 3 - .../oauth/controller/dto/sso-login.query.ts | 14 - .../oauth/controller/dto/system-id.params.ts | 12 - .../controller/dto/user-migration.response.ts | 7 - .../controller/oauth-sso.controller.spec.ts | 6 +- .../oauth/mapper/user-migration.mapper.ts | 12 - .../src/modules/oauth/oauth-api.module.ts | 19 +- apps/server/src/modules/oauth/oauth.module.ts | 12 +- .../oauth/service/oauth.service.spec.ts | 629 ++++++++++++------ .../modules/oauth/service/oauth.service.ts | 68 +- .../src/modules/server/server.module.ts | 10 +- .../api-test/user-login-migration.api.spec.ts | 21 +- .../controller/dto/index.ts | 2 - .../dto/request/page-type.query.param.ts | 17 - .../dto/response/page-content.response.ts | 18 - .../user-login-migration.controller.ts | 2 +- .../user-migration.controller.spec.ts | 67 -- .../controller/user-migration.controller.ts | 27 - .../user-login-migration/error/index.ts | 7 - .../error/oauth-migration.error.spec.ts | 31 - .../error/oauth-migration.error.ts | 28 - .../error/school-migration.error.ts | 17 - .../error/user-login-migration.error.ts | 16 - .../src/modules/user-login-migration/index.ts | 1 - .../interface/page-types.enum.ts | 5 - .../loggable/debug/index.ts | 3 + ...hool-migration-successful.loggable.spec.ts | 42 ++ .../school-migration-successful.loggable.ts | 18 + .../user-migration-started.loggable.spec.ts | 34 + .../debug/user-migration-started.loggable.ts | 16 + ...user-migration-successful.loggable.spec.ts | 34 + .../user-migration-successful.loggable.ts | 16 + ...-number-missing.loggable-exception.spec.ts | 30 + ...chool-number-missing.loggable-exception.ts | 19 + .../user-login-migration/loggable/index.ts | 10 + ...login-migration.loggable-exception.spec.ts | 35 + ...user-login-migration.loggable-exception.ts | 21 + ...peration-failed.loggable-exception.spec.ts | 32 + ...ase-operation-failed.loggable-exception.ts | 29 + ...number-mismatch.loggable-exception.spec.ts | 34 + ...hool-number-mismatch.loggable-exception.ts | 32 + ...-number-missing.loggable-exception.spec.ts | 32 + ...chool-number-missing.loggable-exception.ts | 0 ...ation-already-closed.loggable-exception.ts | 0 ...grace-period-expired-loggable.exception.ts | 0 ...-migration-not-found.loggable-exception.ts | 0 ...peration-failed.loggable-exception.spec.ts | 32 + ...ase-operation-failed.loggable-exception.ts | 24 + .../user-login-migration/mapper/index.ts | 1 - .../mapper/page-content.mapper.spec.ts | 38 -- .../mapper/page-content.mapper.ts | 15 - .../mapper/user-login-migration.mapper.ts | 2 + .../service/school-migration.service.spec.ts | 424 +++++------- .../service/school-migration.service.ts | 168 ++--- .../user-login-migration.service.spec.ts | 605 ++++------------- .../service/user-login-migration.service.ts | 140 ++-- .../service/user-migration.service.spec.ts | 492 ++++---------- .../service/user-migration.service.ts | 174 +---- .../uc/close-user-login-migration.uc.spec.ts | 93 +-- .../uc/close-user-login-migration.uc.ts | 48 +- .../restart-user-login-migration.uc.spec.ts | 168 ++--- .../uc/restart-user-login-migration.uc.ts | 51 +- .../uc/start-user-login-migration.uc.spec.ts | 10 +- .../uc/start-user-login-migration.uc.ts | 13 +- .../uc/toggle-user-login-migration.uc.spec.ts | 10 +- .../uc/toggle-user-login-migration.uc.ts | 10 +- .../uc/user-login-migration.uc.spec.ts | 568 +++++----------- .../uc/user-login-migration.uc.ts | 115 +--- .../user-login-migration-api.module.ts | 11 +- 94 files changed, 1936 insertions(+), 3585 deletions(-) create mode 100644 apps/server/src/modules/authentication/loggable/index.ts create mode 100644 apps/server/src/modules/authentication/loggable/school-in-migration.loggable-exception.spec.ts rename apps/server/src/modules/authentication/{errors/school-in-migration.error.ts => loggable/school-in-migration.loggable-exception.ts} (51%) delete mode 100644 apps/server/src/modules/legacy-school/controller/dto/index.ts delete mode 100644 apps/server/src/modules/legacy-school/controller/dto/migration.body.ts delete mode 100644 apps/server/src/modules/legacy-school/controller/dto/migration.response.ts delete mode 100644 apps/server/src/modules/legacy-school/controller/dto/school.params.ts delete mode 100644 apps/server/src/modules/legacy-school/controller/legacy-school.controller.spec.ts delete mode 100644 apps/server/src/modules/legacy-school/controller/legacy-school.controller.ts delete mode 100644 apps/server/src/modules/legacy-school/legacy-school-api.module.ts rename apps/server/src/modules/legacy-school/{error => loggable}/index.ts (100%) rename apps/server/src/modules/legacy-school/{error => loggable}/school-number-duplicate.loggable-exception.spec.ts (100%) rename apps/server/src/modules/legacy-school/{error => loggable}/school-number-duplicate.loggable-exception.ts (100%) delete mode 100644 apps/server/src/modules/legacy-school/mapper/migration.mapper.spec.ts delete mode 100644 apps/server/src/modules/legacy-school/mapper/migration.mapper.ts delete mode 100644 apps/server/src/modules/legacy-school/uc/dto/oauth-migration.dto.ts delete mode 100644 apps/server/src/modules/legacy-school/uc/index.ts delete mode 100644 apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts delete mode 100644 apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts delete mode 100644 apps/server/src/modules/oauth/controller/dto/sso-login.query.ts delete mode 100644 apps/server/src/modules/oauth/controller/dto/system-id.params.ts delete mode 100644 apps/server/src/modules/oauth/controller/dto/user-migration.response.ts delete mode 100644 apps/server/src/modules/oauth/mapper/user-migration.mapper.ts delete mode 100644 apps/server/src/modules/user-login-migration/controller/dto/request/page-type.query.param.ts delete mode 100644 apps/server/src/modules/user-login-migration/controller/dto/response/page-content.response.ts delete mode 100644 apps/server/src/modules/user-login-migration/controller/user-migration.controller.spec.ts delete mode 100644 apps/server/src/modules/user-login-migration/controller/user-migration.controller.ts delete mode 100644 apps/server/src/modules/user-login-migration/error/index.ts delete mode 100644 apps/server/src/modules/user-login-migration/error/oauth-migration.error.spec.ts delete mode 100644 apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts delete mode 100644 apps/server/src/modules/user-login-migration/error/school-migration.error.ts delete mode 100644 apps/server/src/modules/user-login-migration/error/user-login-migration.error.ts delete mode 100644 apps/server/src/modules/user-login-migration/interface/page-types.enum.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/index.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/school-migration-successful.loggable.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-started.loggable.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-successful.loggable.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/external-school-number-missing.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/invalid-user-login-migration.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/school-migration-database-operation-failed.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/school-number-mismatch.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/school-number-missing.loggable-exception.spec.ts rename apps/server/src/modules/user-login-migration/{error => loggable}/school-number-missing.loggable-exception.ts (100%) rename apps/server/src/modules/user-login-migration/{error => loggable}/user-login-migration-already-closed.loggable-exception.ts (100%) rename apps/server/src/modules/user-login-migration/{error => loggable}/user-login-migration-grace-period-expired-loggable.exception.ts (100%) rename apps/server/src/modules/user-login-migration/{error => loggable}/user-login-migration-not-found.loggable-exception.ts (100%) create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-migration-database-operation-failed.loggable-exception.ts delete mode 100644 apps/server/src/modules/user-login-migration/mapper/page-content.mapper.spec.ts delete mode 100644 apps/server/src/modules/user-login-migration/mapper/page-content.mapper.ts 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/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/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index f67f620175d..e6bf4ef2fa6 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,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +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 { 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 +68,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 +102,7 @@ describe('Oauth2Strategy', () => { refreshToken: 'refreshToken', }) ); - oauthService.provisionUser.mockResolvedValue({ user: undefined, redirect: '' }); + oauthService.provisionUser.mockResolvedValue(null); }; it('should throw a SchoolInMigrationError', async () => { @@ -111,7 +111,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 +126,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..e83e9174abc 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,14 +1,14 @@ -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 { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { UserDO } from '@shared/domain/domainobject/user.do'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; -import { SchoolInMigrationError } from '../errors/school-in-migration.error'; import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { SchoolInMigrationLoggableException } from '../loggable'; import { CurrentUserMapper } from '../mapper'; @Injectable() @@ -22,14 +22,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/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/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/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..9a484a52d47 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,19 +1,18 @@ -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 { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningService } from '@modules/provisioning'; import { OauthDataDto } from '@modules/provisioning/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; 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 +26,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 +58,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 +76,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 +146,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/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/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 100% 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 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/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-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/school-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts index 8addd2afae6..988e6de9b01 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); - - expect(func).not.toThrow(); - }); - }); + it('should save the migrated school and add the system', async () => { + const { school, targetSystemId, targetExternalId, sourceExternalId } = setup(); - describe('when current date is after finish date', () => { - const setup = () => { - jest.setSystemTime(new Date('2023-05-03')); + await service.migrateSchool({ ...school }, targetExternalId, targetSystemId); - 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 error = new Error('Cannot save'); - const userDO: UserDO = userDoFactory.buildWithId({ schoolId: schoolDO.id }, new ObjectId().toHexString(), {}); + 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'; - - 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()); + 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(schoolDO); - }); + await expect( + service.getSchoolForMigration(userId, targetExternalId, otherOfficialSchoolNumber) + ).rejects.toThrow(new SchoolNumberMismatchLoggableException(officialSchoolNumber, otherOfficialSchoolNumber)); }); }); }); @@ -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', () => { 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..aa5173dcff6 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,99 @@ -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 { + 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) { + try { + await this.schoolService.save(originalSchoolDO); + } catch (error: unknown) { + this.logger.warning( + new SchoolMigrationDatabaseOperationFailedLoggableException(originalSchoolDO, 'rollback', error) ); } + } - const schoolMigrated: boolean = this.hasSchoolMigrated(externalId, existingSchool.externalId); + 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); + + 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; + } + + 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 +105,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 { + 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,42 +127,9 @@ 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 { 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..ac7f3bc4740 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 { + UserLoginMigrationGracePeriodExpiredLoggableException, + UserLoginMigrationNotFoundLoggableException, +} 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(); - const func = async () => service.restartMigration(schoolId); + await expect(service.restartMigration({ ...userLoginMigration })).rejects.toThrow(); - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return throw an error', async () => { + const { userLoginMigration, dateInThePast } = setup(); + + await expect(service.restartMigration({ ...userLoginMigration })).rejects.toThrow( + new UserLoginMigrationGracePeriodExpiredLoggableException(userLoginMigration.id as string, dateInThePast) + ); }); }); }); @@ -999,7 +624,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 +631,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(); - const func = () => service.closeMigration(schoolId); + await expect(service.closeMigration({ ...userLoginMigration })).rejects.toThrow(); - await expect(func).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + expect(userLoginMigrationRepo.save).not.toHaveBeenCalled(); + }); + + it('should return throw an error', async () => { + const { userLoginMigration, dateInThePast } = setup(); + + 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..04f74f8408c 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 { + UserLoginMigrationGracePeriodExpiredLoggableException, + UserLoginMigrationNotFoundLoggableException, +} 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,23 +32,23 @@ 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 { + public async setMigrationMandatory(schoolId: string, mandatory: boolean): Promise { + // this.checkGracePeriod(userLoginMigration); let userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationRepo.findBySchoolId(schoolId); if (!userLoginMigration) { @@ -126,14 +66,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 +89,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 +127,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 +135,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 +161,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..3fcecce5196 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 as string)); - 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..0f7c615bfbd 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 { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Logger } from '@src/core/logger'; +import { + SchoolNumberMissingLoggableException, + UserLoginMigrationAlreadyClosedLoggableException, + UserLoginMigrationStartLoggable, +} from '../loggable'; import { UserLoginMigrationService } from '../service'; @Injectable() 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..75fffd4a3c2 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,20 @@ 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'; +} 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; 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..bb020379b9c 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,14 @@ -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 { Injectable } from '@nestjs/common'; +import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { Logger } from '@src/core/logger'; import { UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationGracePeriodExpiredLoggableException, + UserLoginMigrationMandatoryLoggable, UserLoginMigrationNotFoundLoggableException, -} from '../error'; -import { UserLoginMigrationMandatoryLoggable } from '../loggable'; +} from '../loggable'; import { UserLoginMigrationService } from '../service'; @Injectable() 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..bebd6b7115a 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,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthenticationService } from '@modules/authentication/services/authentication.service'; +import { Action, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; +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 { 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 +19,13 @@ 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 } from '../loggable'; +import { InvalidUserLoginMigrationLoggableException } from '../loggable/invalid-user-login-migration.loggable-exception'; 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 +36,6 @@ describe('UserLoginMigrationUc', () => { let userMigrationService: DeepMocked; let authenticationService: DeepMocked; let authorizationService: DeepMocked; - let logger: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -78,8 +76,8 @@ describe('UserLoginMigrationUc', () => { useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, ], }).compile(); @@ -93,7 +91,6 @@ describe('UserLoginMigrationUc', () => { userMigrationService = module.get(UserMigrationService); authenticationService = module.get(AuthenticationService); authorizationService = module.get(AuthorizationService); - logger = module.get(LegacyLogger); }); afterAll(async () => { @@ -104,34 +101,6 @@ 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 = () => { @@ -143,7 +112,7 @@ describe('UserLoginMigrationUc', () => { startedAt: new Date(), }); - userLoginMigrationService.findMigrationByUser.mockResolvedValue(migrations); + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(migrations); return { userId, migrations }; }; @@ -164,7 +133,7 @@ describe('UserLoginMigrationUc', () => { const setup = () => { const userId = 'userId'; - userLoginMigrationService.findMigrationByUser.mockResolvedValue(null); + userLoginMigrationService.findMigrationByUser.mockResolvedValueOnce(null); return { userId }; }; @@ -212,8 +181,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 }; }; @@ -244,8 +213,8 @@ describe('UserLoginMigrationUc', () => { const user: User = userFactory.buildWithId(); - userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); - authorizationService.getUserWithPermissions.mockResolvedValue(user); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { user, schoolId }; }; @@ -273,8 +242,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 +262,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 +280,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 +346,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 +360,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 +426,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 +476,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..24442147e5e 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,21 @@ -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 { ForbiddenException, Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +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 +28,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 +71,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 +94,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 - ); - - // 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 }); - } + await this.userMigrationService.migrateUser(currentUserId, data.externalUser.externalId, targetSystemId); - 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 {} From e28eee00f52c5961dc0f1dec8edaa97e0091ad51 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Tue, 14 Nov 2023 12:34:41 +0100 Subject: [PATCH 2/3] N21-1336 change tool status determination method (#4534) * coompute tool status via paramter validation and feature flag --- .../common/service/common-tool.service.ts | 4 + .../context-external-tool.module.ts | 14 +- .../context-external-tool/service/index.ts | 1 - .../service/tool-reference.service.spec.ts | 14 +- .../service/tool-reference.service.ts | 9 +- .../service/tool-version-service.spec.ts | 196 ++++++++++++++++++ .../service/tool-version-service.ts | 44 ++++ .../uc/context-external-tool.uc.spec.ts | 3 +- .../uc/context-external-tool.uc.ts | 3 +- .../external-tool/external-tool.module.ts | 6 +- ...xternal-tool-version-increment.service.ts} | 2 +- .../external-tool-version.service.spec.ts | 6 +- .../service/external-tool.service.spec.ts | 10 +- .../service/external-tool.service.ts | 4 +- .../tool/external-tool/service/index.ts | 2 +- .../school-external-tool.module.ts | 3 +- ...l-external-tool-validation.service.spec.ts | 51 ++++- ...school-external-tool-validation.service.ts | 11 +- .../school-external-tool.service.spec.ts | 131 +++++++++--- .../service/school-external-tool.service.ts | 24 ++- apps/server/src/modules/tool/tool-config.ts | 4 + .../service/tool-launch.service.spec.ts | 25 ++- .../service/tool-launch.service.ts | 13 +- config/default.schema.json | 5 + 24 files changed, 496 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts rename apps/server/src/modules/tool/external-tool/service/{external-tool-version.service.ts => external-tool-version-increment.service.ts} (98%) 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/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/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..6b4f69d6686 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 @@ -6,6 +6,7 @@ import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; describe('SchoolExternalToolValidationService', () => { let module: TestingModule; @@ -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, @@ -87,9 +100,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..3474aa90f5e 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,15 +1,17 @@ -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 { SchoolExternalTool } from '../domain'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; @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 { @@ -17,8 +19,9 @@ export class SchoolExternalToolValidationService { 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/config/default.schema.json b/config/default.schema.json index ae43648f3ea..b971968a4ae 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1323,6 +1323,11 @@ "default": false, "description": "Enables groups of type class in courses" }, + "FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the calculation of the outdated status of an external tool without the usage of the db attribute version" + }, "TSP_SCHOOL_SYNCER": { "type": "object", "description": "TSP School Syncer properties", From b3b4cee3b13c783c3a67619db5cab92d1b743e81 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Tue, 14 Nov 2023 16:16:57 +0100 Subject: [PATCH 3/3] N21-1447 user-login-migration refactorings (#4556) --- .../src/modules/authentication/index.ts | 1 + .../modules/authentication/services/index.ts | 2 + .../strategy/oauth2.strategy.spec.ts | 3 +- .../strategy/oauth2.strategy.ts | 3 +- apps/server/src/modules/oauth/index.ts | 1 + .../server/src/modules/oauth/service/index.ts | 3 + .../modules/oauth/service/oauth.service.ts | 3 +- .../modules/oauth/uc/hydra-oauth.uc.spec.ts | 3 +- apps/server/src/modules/provisioning/index.ts | 2 +- ...ation-already-closed.loggable-exception.ts | 2 +- ...login-migration-mandatory.loggable.spec.ts | 33 ++++++ ...user-login-migration-mandatory.loggable.ts | 2 +- ...ser-login-migration-start.loggable.spec.ts | 32 ++++++ .../user-login-migration-start.loggable.ts | 2 +- .../user-login-migration/service/dto/index.ts | 1 - .../service/dto/school-migration-flags.ts | 4 - .../service/migration-check.service.spec.ts | 3 +- .../service/migration-check.service.ts | 45 +++++--- .../service/school-migration.service.spec.ts | 8 +- .../service/school-migration.service.ts | 16 ++- .../user-login-migration.service.spec.ts | 96 ++++++++-------- .../service/user-login-migration.service.ts | 14 ++- .../uc/restart-user-login-migration.uc.ts | 2 +- .../uc/start-user-login-migration.uc.ts | 13 +-- .../uc/toggle-user-login-migration.uc.spec.ts | 103 +++++------------- .../uc/toggle-user-login-migration.uc.ts | 31 ++---- .../uc/user-login-migration.uc.spec.ts | 35 +++--- .../uc/user-login-migration.uc.ts | 8 +- 28 files changed, 248 insertions(+), 223 deletions(-) create mode 100644 apps/server/src/modules/authentication/services/index.ts create mode 100644 apps/server/src/modules/oauth/service/index.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts delete mode 100644 apps/server/src/modules/user-login-migration/service/dto/school-migration-flags.ts 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/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 e6bf4ef2fa6..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,8 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; 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 { OAuthTokenDto, OAuthService } from '@modules/oauth'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, RoleName } from '@shared/domain'; diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index e83e9174abc..e5bc6f942f8 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,7 +1,6 @@ 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 { OAuthTokenDto, OAuthService } from '@modules/oauth'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { UserDO } from '@shared/domain/domainobject/user.do'; 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/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.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 9a484a52d47..5e787d79082 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,7 +1,6 @@ import { DefaultEncryptionService, IEncryptionService } from '@infra/encryption'; import { LegacySchoolService } from '@modules/legacy-school'; -import { ProvisioningService } from '@modules/provisioning'; -import { OauthDataDto } from '@modules/provisioning/dto'; +import { ProvisioningService, OauthDataDto } from '@modules/provisioning'; import { SystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; 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/user-login-migration/loggable/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 index 321eba624f9..4e5fec1c31b 100644 --- a/apps/server/src/modules/user-login-migration/loggable/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/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/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/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 988e6de9b01..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 @@ -303,8 +303,8 @@ describe(SchoolMigrationService.name, () => { 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'), @@ -379,7 +379,7 @@ describe(SchoolMigrationService.name, () => { }; }; - it('should call userLoginMigrationRepo.findBySchoolId', async () => { + it('should find user login migration by school id', async () => { setup(); await service.hasSchoolMigratedUser('schoolId'); @@ -399,7 +399,7 @@ describe(SchoolMigrationService.name, () => { }; }; - 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 aa5173dcff6..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 @@ -20,7 +20,11 @@ export class SchoolMigrationService { private readonly userLoginMigrationRepo: UserLoginMigrationRepo ) {} - async migrateSchool(existingSchool: LegacySchoolDo, externalId: string, targetSystemId: string): Promise { + public async migrateSchool( + existingSchool: LegacySchoolDo, + externalId: string, + targetSystemId: string + ): Promise { const schoolDOCopy: LegacySchoolDo = new LegacySchoolDo({ ...existingSchool }); try { @@ -45,7 +49,7 @@ export class SchoolMigrationService { await this.schoolService.save(school); } - private async tryRollbackMigration(originalSchoolDO: LegacySchoolDo) { + private async tryRollbackMigration(originalSchoolDO: LegacySchoolDo): Promise { try { await this.schoolService.save(originalSchoolDO); } catch (error: unknown) { @@ -55,7 +59,7 @@ export class SchoolMigrationService { } } - async getSchoolForMigration( + public async getSchoolForMigration( userId: string, externalId: string, officialSchoolNumber: string @@ -89,7 +93,7 @@ export class SchoolMigrationService { return isExternalIdEquivalent; } - async markUnmigratedUsersAsOutdated(userLoginMigration: UserLoginMigrationDO): Promise { + public async markUnmigratedUsersAsOutdated(userLoginMigration: UserLoginMigrationDO): Promise { const startTime: number = performance.now(); const notMigratedUsers: Page = await this.userService.findUsers({ @@ -112,7 +116,7 @@ export class SchoolMigrationService { ); } - async unmarkOutdatedUsers(userLoginMigration: UserLoginMigrationDO): Promise { + public async unmarkOutdatedUsers(userLoginMigration: UserLoginMigrationDO): Promise { const startTime: number = performance.now(); const migratedUsers: Page = await this.userService.findUsers({ @@ -132,7 +136,7 @@ export class SchoolMigrationService { ); } - 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 ac7f3bc4740..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 @@ -11,8 +11,8 @@ import { EntityId, LegacySchoolDo, SchoolFeatures, UserDO, UserLoginMigrationDO import { UserLoginMigrationRepo } from '@shared/repo'; import { legacySchoolDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { + UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, } from '../loggable'; import { UserLoginMigrationService } from './user-login-migration.service'; @@ -523,34 +523,25 @@ describe(UserLoginMigrationService.name, () => { 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, }); }); @@ -558,65 +549,76 @@ describe(UserLoginMigrationService.name, () => { 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); }); }); }); 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 04f74f8408c..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 @@ -6,8 +6,8 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId, LegacySchoolDo, SchoolFeatures, SystemTypeEnum, UserDO, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationRepo } from '@shared/repo'; import { + UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, } from '../loggable'; @Injectable() @@ -47,12 +47,14 @@ export class UserLoginMigrationService { return updatedUserLoginMigration; } - public async setMigrationMandatory(schoolId: string, mandatory: boolean): Promise { - // this.checkGracePeriod(userLoginMigration); - 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) { 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 3fcecce5196..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 @@ -38,7 +38,7 @@ export class RestartUserLoginMigrationUc { await this.schoolMigrationService.unmarkOutdatedUsers(updatedUserLoginMigration); - this.logger.info(new UserLoginMigrationStartLoggable(userId, updatedUserLoginMigration.id as string)); + this.logger.info(new UserLoginMigrationStartLoggable(userId, updatedUserLoginMigration.id)); return updatedUserLoginMigration; } 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 0f7c615bfbd..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,7 +1,7 @@ import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { EntityId, LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { Logger } from '@src/core/logger'; import { SchoolNumberMissingLoggableException, @@ -21,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( @@ -31,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 75fffd4a3c2..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 @@ -6,11 +6,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { legacySchoolDoFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { - UserLoginMigrationAlreadyClosedLoggableException, - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationNotFoundLoggableException, -} from '../loggable'; +import { ObjectId } from 'bson'; +import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { UserLoginMigrationService } from '../service'; import { ToggleUserLoginMigrationUc } from './toggle-user-login-migration.uc'; @@ -78,13 +75,13 @@ describe(ToggleUserLoginMigrationUc.name, () => { 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.name, () => { }); 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.name, () => { 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.name, () => { }); 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.name, () => { 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.name, () => { 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 bb020379b9c..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 { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common'; -import { LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; +import { EntityId, LegacySchoolDo, Permission, User, UserLoginMigrationDO } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { - UserLoginMigrationAlreadyClosedLoggableException, - UserLoginMigrationGracePeriodExpiredLoggableException, - UserLoginMigrationMandatoryLoggable, - UserLoginMigrationNotFoundLoggableException, -} from '../loggable'; +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 bebd6b7115a..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,12 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; +import { AuthenticationService } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -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 { 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'; @@ -20,8 +24,7 @@ import { userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ExternalSchoolNumberMissingLoggableException } from '../loggable'; -import { InvalidUserLoginMigrationLoggableException } from '../loggable/invalid-user-login-migration.loggable-exception'; +import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException } from '../loggable'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; import { UserLoginMigrationUc } from './user-login-migration.uc'; @@ -104,7 +107,7 @@ describe(UserLoginMigrationUc.name, () => { 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', @@ -117,7 +120,7 @@ describe(UserLoginMigrationUc.name, () => { 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 }); @@ -131,14 +134,14 @@ describe(UserLoginMigrationUc.name, () => { describe('when a user has no migration available', () => { const setup = () => { - const userId = 'userId'; + const userId = new ObjectId().toHexString(); 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 }); @@ -152,12 +155,12 @@ describe(UserLoginMigrationUc.name, () => { 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' }); @@ -172,7 +175,7 @@ describe(UserLoginMigrationUc.name, () => { 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, @@ -209,7 +212,7 @@ describe(UserLoginMigrationUc.name, () => { describe('when a user login migration does not exist', () => { const setup = () => { - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); const user: User = userFactory.buildWithId(); @@ -230,7 +233,7 @@ describe(UserLoginMigrationUc.name, () => { describe('when the authorization fails', () => { const setup = () => { - const schoolId = 'schoolId'; + const schoolId = new ObjectId().toHexString(); const user: User = userFactory.buildWithId(); 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 24442147e5e..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,9 +1,7 @@ -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; +import { AuthenticationService } from '@modules/authentication'; 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 { 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, LegacySchoolDo, Page, Permission, User, UserLoginMigrationDO } from '@shared/domain';