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/6] 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/6] 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/6] 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'; From 8f1aea39fd4bed99cb4bed845b05df2228ae1909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Wed, 15 Nov 2023 16:11:46 +0100 Subject: [PATCH 4/6] N21-1488 Fix optional external tool parameter with regex (#4558) --- .../common-tool-validation.service.spec.ts | 178 +++++++++--------- .../service/common-tool-validation.service.ts | 46 +++-- .../api-test/tool-context.api.spec.ts | 55 ++++-- .../context-external-tool-request.mapper.ts | 2 +- ...ontext-external-tool-validation.service.ts | 4 +- .../api-test/tool-school.api.spec.ts | 69 ++++--- .../school-external-tool-request.mapper.ts | 4 +- ...l-external-tool-validation.service.spec.ts | 10 +- ...school-external-tool-validation.service.ts | 5 +- .../factory/external-tool-entity.factory.ts | 2 - 10 files changed, 212 insertions(+), 163 deletions(-) diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts index d3ca547a854..1f76ec01a14 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.spec.ts @@ -109,104 +109,93 @@ describe('CommonToolValidationService', () => { }); }); - describe('checkForDuplicateParameters', () => { - describe('when given parameters has a case sensitive duplicate', () => { - const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - parameters: [ - { name: 'nameDuplicate', value: 'value' }, - { name: 'nameDuplicate', value: 'value' }, - ], - }); + describe('checkCustomParameterEntries', () => { + const createTools = ( + externalToolMock?: Partial, + schoolExternalToolMock?: Partial, + contextExternalToolMock?: Partial + ) => { + const externalTool: ExternalTool = new ExternalTool({ + ...externalToolFactory.buildWithId(), + ...externalToolMock, + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + ...schoolExternalToolFactory.buildWithId(), + ...schoolExternalToolMock, + }); + const schoolExternalToolId = schoolExternalTool.id as string; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ...contextExternalToolFactory.buildWithId(), + ...contextExternalToolMock, + }); - return { - schoolExternalTool, - }; + return { + externalTool, + schoolExternalTool, + schoolExternalToolId, + contextExternalTool, }; + }; - it('should throw error', () => { - const { schoolExternalTool } = setup(); - - const func = () => service.checkForDuplicateParameters(schoolExternalTool); - - expect(func).toThrow('tool_param_duplicate'); - }); - }); - - describe('when given parameters has case insensitive duplicate', () => { + describe('when a parameter is a duplicate', () => { const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + const externalTool: ExternalTool = externalToolFactory.buildWithId({ parameters: [ - { name: 'nameDuplicate', value: 'value' }, - { name: 'nameduplicate', value: 'value' }, + customParameterFactory.build({ + name: 'duplicate', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: true, + }), + ], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + parameters: [ + { name: 'duplicate', value: undefined }, + { name: 'duplicate', value: undefined }, ], }); return { + externalTool, schoolExternalTool, }; }; - it('should throw error when given parameters has case insensitive duplicate', () => { - const { schoolExternalTool } = setup(); + it('should throw error', () => { + const { externalTool, schoolExternalTool } = setup(); - const func = () => service.checkForDuplicateParameters(schoolExternalTool); + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); expect(func).toThrowError('tool_param_duplicate'); }); }); - describe('when given parameters has no duplicates', () => { + describe('when a parameter is unknown', () => { const setup = () => { - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - parameters: [ - { name: 'nameNoDuplicate1', value: 'value' }, - { name: 'nameNoDuplicate2', value: 'value' }, - ], + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + parameters: [], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + parameters: [{ name: 'unknownParameter', value: undefined }], }); return { + externalTool, schoolExternalTool, }; }; - it('when given parameters has no duplicates should return without error', () => { - const { schoolExternalTool } = setup(); + it('should throw error', () => { + const { externalTool, schoolExternalTool } = setup(); - const func = () => service.checkForDuplicateParameters(schoolExternalTool); + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); - expect(func).not.toThrowError('tool_param_duplicate'); + expect(func).toThrowError('tool_param_unknown'); }); }); - }); - - describe('checkCustomParameterEntries', () => { - const createTools = ( - externalToolMock?: Partial, - schoolExternalToolMock?: Partial, - contextExternalToolMock?: Partial - ) => { - const externalTool: ExternalTool = new ExternalTool({ - ...externalToolFactory.buildWithId(), - ...externalToolMock, - }); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - ...schoolExternalToolFactory.buildWithId(), - ...schoolExternalToolMock, - }); - const schoolExternalToolId = schoolExternalTool.id as string; - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ - ...contextExternalToolFactory.buildWithId(), - ...contextExternalToolMock, - }); - - return { - externalTool, - schoolExternalTool, - schoolExternalToolId, - contextExternalTool, - }; - }; describe('when checking parameter is required', () => { describe('and given parameter is not optional and parameter value is empty', () => { @@ -280,16 +269,23 @@ describe('CommonToolValidationService', () => { describe('when parameter is not school or context', () => { const setup = () => { - const notSchoolParam: CustomParameter = customParameterFactory.build({ - name: 'notSchoolParam', - scope: CustomParameterScope.GLOBAL, - type: CustomParameterType.BOOLEAN, - }); - const { externalTool, schoolExternalTool } = createTools( - { parameters: [notSchoolParam] }, { - parameters: [{ name: 'name', value: 'true' }], + parameters: [ + customParameterFactory.build({ + name: 'notSchoolParam', + scope: CustomParameterScope.GLOBAL, + type: CustomParameterType.BOOLEAN, + }), + customParameterFactory.build({ + name: 'schoolParam', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.BOOLEAN, + }), + ], + }, + { + parameters: [{ name: 'schoolParam', value: 'true' }], } ); @@ -320,7 +316,7 @@ describe('CommonToolValidationService', () => { const { externalTool, schoolExternalTool } = createTools( { parameters: [missingParam] }, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [], } ); @@ -339,18 +335,26 @@ describe('CommonToolValidationService', () => { }); }); - describe('when parameter is optional but is missing on params', () => { + describe('when parameter is optional and was not defined', () => { const setup = () => { - const param: CustomParameter = customParameterFactory.build({ - name: 'notChecked', - scope: CustomParameterScope.SCHOOL, - isOptional: true, - }); - const { externalTool, schoolExternalTool } = createTools( - { parameters: [param] }, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [ + customParameterFactory.build({ + name: 'optionalParameter', + scope: CustomParameterScope.SCHOOL, + isOptional: true, + }), + customParameterFactory.build({ + name: 'requiredParameter', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + ], + }, + { + parameters: [{ name: 'requiredParameter', value: 'value' }], } ); @@ -360,7 +364,7 @@ describe('CommonToolValidationService', () => { }; }; - it('should return without error ', () => { + it('should return without error', () => { const { externalTool, schoolExternalTool } = setup(); const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); @@ -385,7 +389,7 @@ describe('CommonToolValidationService', () => { }, undefined, { - parameters: [{ name: 'anotherParam', value: 'value' }], + parameters: [], } ); diff --git a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts index 3ee9ee7d465..e6c3a31b288 100644 --- a/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool-validation.service.ts @@ -29,12 +29,25 @@ export class CommonToolValidationService { return isValid; } - public checkForDuplicateParameters(validatableTool: ValidatableTool): void { - const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => - name.toLowerCase() + public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { + this.checkForDuplicateParameters(validatableTool); + + const parametersForScope: CustomParameter[] = (loadedExternalTool.parameters ?? []).filter( + (param: CustomParameter) => + (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || + (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) ); + this.checkForUnknownParameters(validatableTool, parametersForScope); + + this.checkValidityOfParameters(validatableTool, parametersForScope); + } + + private checkForDuplicateParameters(validatableTool: ValidatableTool): void { + const caseInsensitiveNames: string[] = validatableTool.parameters.map(({ name }: CustomParameterEntry) => name); + const uniqueNames: Set = new Set(caseInsensitiveNames); + if (uniqueNames.size !== validatableTool.parameters.length) { throw new ValidationError( `tool_param_duplicate: The tool ${validatableTool.id ?? ''} contains multiple of the same custom parameters.` @@ -42,28 +55,33 @@ export class CommonToolValidationService { } } - public checkCustomParameterEntries(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): void { - if (loadedExternalTool.parameters) { - for (const param of loadedExternalTool.parameters) { - this.checkScopeAndValidateParameter(validatableTool, param); + private checkForUnknownParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { + for (const entry of validatableTool.parameters) { + const foundParameter: CustomParameter | undefined = parametersForScope.find( + ({ name }: CustomParameter): boolean => name === entry.name + ); + + if (!foundParameter) { + throw new ValidationError( + `tool_param_unknown: The parameter with name ${entry.name} is not part of this tool.` + ); } } } - private checkScopeAndValidateParameter(validatableTool: ValidatableTool, param: CustomParameter): void { - const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( - ({ name }: CustomParameterEntry): boolean => name.toLowerCase() === param.name.toLowerCase() - ); + private checkValidityOfParameters(validatableTool: ValidatableTool, parametersForScope: CustomParameter[]): void { + for (const param of parametersForScope) { + const foundEntry: CustomParameterEntry | undefined = validatableTool.parameters.find( + ({ name }: CustomParameterEntry): boolean => name === param.name + ); - if (param.scope === CustomParameterScope.SCHOOL && validatableTool instanceof SchoolExternalTool) { - this.validateParameter(param, foundEntry); - } else if (param.scope === CustomParameterScope.CONTEXT && validatableTool instanceof ContextExternalTool) { this.validateParameter(param, foundEntry); } } private validateParameter(param: CustomParameter, foundEntry: CustomParameterEntry | undefined): void { this.checkOptionalParameter(param, foundEntry); + if (foundEntry) { this.checkParameterType(foundEntry, param); this.checkParameterRegex(foundEntry, param); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index eb570130c4e..af14eb379f3 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -17,9 +17,8 @@ import { userFactory, } from '@shared/testing'; import { ObjectId } from 'bson'; -import { CustomParameterScope, ToolContextType } from '../../../common/enum'; +import { CustomParameterScope, CustomParameterType, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; -import { CustomParameterEntryResponse } from '../../../school-external-tool/controller/dto'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; import { @@ -66,10 +65,27 @@ describe('ToolContextController (API)', () => { const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const paramEntry: CustomParameterEntryResponse = { name: 'name', value: 'value' }; + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.CONTEXT, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], + version: 1, + }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, school, - schoolParameters: [paramEntry], + schoolParameters: [], toolVersion: 1, }); @@ -78,7 +94,10 @@ describe('ToolContextController (API)', () => { contextId: course.id, displayName: course.name, contextType: ToolContextType.COURSE, - parameters: [paramEntry], + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: '' }, + ], toolVersion: 1, }; @@ -87,30 +106,30 @@ describe('ToolContextController (API)', () => { const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); - const expected: ContextExternalToolResponse = { - id: expect.any(String), - schoolToolId: postParams.schoolToolId, - contextId: postParams.contextId, - displayName: postParams.displayName, - contextType: postParams.contextType, - parameters: [paramEntry], - toolVersion: postParams.toolVersion, - }; - return { loggedInClient, postParams, - expected, }; }; it('should create a contextExternalTool', async () => { - const { postParams, loggedInClient, expected } = await setup(); + const { postParams, loggedInClient } = await setup(); const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(response.body).toEqual(expect.objectContaining(expected)); + expect(response.body).toEqual({ + id: expect.any(String), + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: undefined }, + ], + toolVersion: postParams.toolVersion, + }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts index 951559afbcc..8e72f5f540b 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-request.mapper.ts @@ -25,7 +25,7 @@ export class ContextExternalToolRequestMapper { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, - value: customParameterParam.value, + value: customParameterParam.value || undefined, }; }); } diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index 2cce83a08b2..b12193eadd5 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -18,7 +18,7 @@ export class ContextExternalToolValidationService { ) {} async validate(contextExternalTool: ContextExternalTool): Promise { - await this.checkDuplicateInContext(contextExternalTool); + await this.checkDuplicateUsesInContext(contextExternalTool); const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId @@ -29,7 +29,7 @@ export class ContextExternalToolValidationService { this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, contextExternalTool); } - private async checkDuplicateInContext(contextExternalTool: ContextExternalTool) { + private async checkDuplicateUsesInContext(contextExternalTool: ContextExternalTool) { let duplicate: ContextExternalTool[] = await this.contextExternalToolService.findContextExternalTools({ schoolToolRef: contextExternalTool.schoolToolRef, context: contextExternalTool.contextRef, diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 3512e66038e..2cca9819b3a 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -1,9 +1,11 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, Permission, SchoolEntity, User } from '@shared/domain'; import { accountFactory, + customParameterEntityFactory, externalToolEntityFactory, schoolExternalToolEntityFactory, schoolFactory, @@ -11,9 +13,8 @@ import { UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ServerTestModule } from '@modules/server'; import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto/tool-configuration-status.response'; -import { ExternalToolEntity } from '../../../external-tool/entity'; +import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; import { SchoolExternalToolEntity } from '../../entity'; import { CustomParameterEntryParam, @@ -66,31 +67,31 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, - parameters: [], + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + customParameterEntityFactory.build({ + name: 'param2', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.BOOLEAN, + isOptional: true, + }), + ], }); - const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; const postParams: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, version: 1, - parameters: [paramEntry], - }; - - const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ - id: expect.any(String), - name: externalToolEntity.name, - schoolId: postParams.schoolId, - toolId: postParams.toolId, - status: ToolConfigurationStatusResponse.LATEST, - toolVersion: postParams.version, parameters: [ - { - name: paramEntry.name, - value: paramEntry.value, - }, + { name: 'param1', value: 'value' }, + { name: 'param2', value: '' }, ], - }); + }; em.persist([ school, @@ -112,7 +113,7 @@ describe('ToolSchoolController (API)', () => { loggedInClientWithMissingPermission, loggedInClient, postParams, - schoolExternalToolResponse, + externalToolEntity, }; }; @@ -125,12 +126,23 @@ describe('ToolSchoolController (API)', () => { }); it('should create an school external tool', async () => { - const { loggedInClient, postParams, schoolExternalToolResponse } = await setup(); + const { loggedInClient, postParams, externalToolEntity } = await setup(); const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(response.body).toEqual(schoolExternalToolResponse); + expect(response.body).toEqual({ + id: expect.any(String), + name: externalToolEntity.name, + schoolId: postParams.schoolId, + toolId: postParams.toolId, + status: ToolConfigurationStatusResponse.LATEST, + toolVersion: postParams.version, + parameters: [ + { name: 'param1', value: 'value' }, + { name: 'param2', value: undefined }, + ], + }); const createdSchoolExternalTool: SchoolExternalToolEntity | null = await em.findOne(SchoolExternalToolEntity, { school: postParams.schoolId, @@ -391,7 +403,14 @@ describe('ToolSchoolController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, - parameters: [], + parameters: [ + customParameterEntityFactory.build({ + name: 'param1', + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + isOptional: false, + }), + ], }); const externalToolEntity2: ExternalToolEntity = externalToolEntityFactory.buildWithId({ version: 1, @@ -422,7 +441,7 @@ describe('ToolSchoolController (API)', () => { accountWithMissingPermission ); - const paramEntry: CustomParameterEntryParam = { name: 'name', value: 'value' }; + const paramEntry: CustomParameterEntryParam = { name: 'param1', value: 'value' }; const postParams: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, @@ -430,7 +449,7 @@ describe('ToolSchoolController (API)', () => { parameters: [paramEntry], }; - const updatedParamEntry: CustomParameterEntryParam = { name: 'name', value: 'updatedValue' }; + const updatedParamEntry: CustomParameterEntryParam = { name: 'param1', value: 'updatedValue' }; const postParamsUpdate: SchoolExternalToolPostParams = { toolId: externalToolEntity.id, schoolId: school.id, diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts index 6617e4ec7fd..eff05c092cb 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { CustomParameterEntry } from '../../common/domain'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; -import { CustomParameterEntry } from '../../common/domain'; @Injectable() export class SchoolExternalToolRequestMapper { @@ -20,7 +20,7 @@ export class SchoolExternalToolRequestMapper { return customParameterParams.map((customParameterParam: CustomParameterEntryParam) => { return { name: customParameterParam.name, - value: customParameterParam.value, + value: customParameterParam.value || undefined, }; }); } diff --git a/apps/server/src/modules/tool/school-external-tool/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 6b4f69d6686..1f2ba7f5eb9 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts @@ -4,9 +4,9 @@ import { externalToolFactory, schoolExternalToolFactory } from '@shared/testing/ import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; describe('SchoolExternalToolValidationService', () => { let module: TestingModule; @@ -82,14 +82,6 @@ describe('SchoolExternalToolValidationService', () => { expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); - it('should call commonToolValidationService.checkForDuplicateParameters', async () => { - const { schoolExternalTool } = setup(); - - await service.validate(schoolExternalTool); - - expect(commonToolValidationService.checkForDuplicateParameters).toHaveBeenCalledWith(schoolExternalTool); - }); - it('should call commonToolValidationService.checkCustomParameterEntries', async () => { const { schoolExternalTool } = setup(); 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 3474aa90f5e..899055e321f 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts @@ -3,8 +3,8 @@ 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'; +import { SchoolExternalTool } from '../domain'; @Injectable() export class SchoolExternalToolValidationService { @@ -15,13 +15,12 @@ export class SchoolExternalToolValidationService { ) {} async validate(schoolExternalTool: SchoolExternalTool): Promise { - this.commonToolValidationService.checkForDuplicateParameters(schoolExternalTool); - const loadedExternalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); if (!this.toolFeatures.toolStatusWithoutVersions) { this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); } + this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, schoolExternalTool); } diff --git a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts index 32c077e2529..39ff2662cf7 100644 --- a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts @@ -82,8 +82,6 @@ export const customParameterEntityFactory = BaseFactory.define Date: Wed, 15 Nov 2023 20:31:58 +0100 Subject: [PATCH 5/6] BC-5629 batch deletion mechanism (#4521) * first commit * add some tests * add test cases and services * add new (almost empty for now) batch deletion app * refactor config vars * add optional env var for specifying delay between the API calls * add usecases and test cases * fix importing * add type in uc * fix import * add references service that'll load all the references to the data we want to delete * fix most of issue form review * add deletion API client with just a single method for now that allows for sending a deletion request * refactor the env vars for configurting the Admin API * add exporting DeletionClientConfig * move references service to the deletion module * delete unused code * add batch deletion service that makes it possible ot queue deletion for many references at once * move some parts of the interface to the interface subdir * add an interface for the batch deletion summary * move some interfaces to a separate subdir * refactor the batch deletion summary interface * add uc for the batch deletion * remove unused annotation * refactor deletion client implementation * add batch deletion service implementation * add UC for the batch deletion * add a console app for the deletion module and a console command to manage deletion requests queue * remove no longer used app, add param to make it possible to define delay between the client calls for the case one would like to queue many thousands of deletion requests at once * remove no longer used separate batch-deletion module (it became a part of the main deletion module) * fix invalid key * remove no longer used config vars * remove no longer used commands * remove no longer used Nest cli config * remove no longer used code * change name of the method that prepares default headers * add builders for most of the interfaces * add builders for the remaining interfaces * add type in catch clause * do some adjustments, move PushDeletionRequestsOptions interface to a separate file * remove unused import * rollback * remove unnecessary indent * remove unnecessary indents * remove empty line * remove repeated imports * refactor some imports to omit calling Configuration.get() on every subpackage import * add builder for the DeletionRequestOutput class * add unit tests for the batch deletion service * add unit tests for the BatchDeletionUc * modify env keys for the Admin API client configuration, refactor the way the deletion module's console is bootstrapped * fix invalid import, remove unused undefined arg * add comment to ignore console.ts file for coverage * move deletion client config interface to a separate file, refactor function that prepares current config, add unit tests for it * fix invalid import * add more test cases to the deletion client unit tests * change invalid import Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> * fix invalid import * add builder for the PushDeletionRequestsOptions class, add unit tests for the DeletionQueueConsole * rename the file containing the deletion module console to deletion.console.ts, add coverage exclusion for it for the Sonar coverage analysis * remove deletion.console.ts from the sonar.coverage.exclusions param as it doesn't seem to work anyway * add deletion.console.ts file to the coverage exclusions (another try with different path) * change name of the file containing the deletion console app * fix some imports * move default value for the ADMIN_API_CLIENT object to default.schema.json * move default for the BASE_URL * move Deletion module console app to the apps/ dir * add separate functino to log error and set exit code * add handling of the case that only CR chars are used as a line separators * add use of the BatchDeletionSummaryBuilder in place of an anonymous object creation * fix some imports/exports * refactor console app flow --------- Co-authored-by: WojciechGrancow Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> --- apps/server/src/apps/deletion-console.app.ts | 31 +++ .../deletion-request-input.builder.spec.ts | 33 +++ .../builder/deletion-request-input.builder.ts | 11 + .../deletion-request-output.builder.spec.ts | 29 +++ .../deletion-request-output.builder.ts | 10 + ...n-request-target-ref-input.builder.spec.ts | 26 +++ ...letion-request-target-ref-input.builder.ts | 7 + .../modules/deletion/client/builder/index.ts | 3 + .../client/deletion-client.config.spec.ts | 41 ++++ .../deletion/client/deletion-client.config.ts | 9 + .../deletion/client/deletion.client.spec.ts | 154 ++++++++++++++ .../deletion/client/deletion.client.ts | 66 ++++++ .../src/modules/deletion/client/index.ts | 3 + .../deletion-client-config.interface.ts | 4 + .../deletion-request-input.interface.ts | 6 + .../deletion-request-output.interface.ts | 4 + ...tion-request-target-ref-input.interface.ts | 4 + .../deletion/client/interface/index.ts | 4 + .../modules/deletion/console/builder/index.ts | 1 + ...sh-delete-requests-options.builder.spec.ts | 43 ++++ .../push-delete-requests-options.builder.ts | 17 ++ .../console/deletion-console.module.ts | 22 ++ .../console/deletion-queue.console.spec.ts | 79 +++++++ .../console/deletion-queue.console.ts | 46 +++++ .../src/modules/deletion/console/index.ts | 1 + .../deletion/console/interface/index.ts | 1 + .../push-delete-requests-options.interface.ts | 6 + apps/server/src/modules/deletion/index.ts | 2 + .../services/batch-deletion.service.spec.ts | 97 +++++++++ .../services/batch-deletion.service.ts | 55 +++++ .../deletion/services/builder/index.ts | 2 + ...eue-deletion-request-input.builder.spec.ts | 27 +++ .../queue-deletion-request-input.builder.ts | 7 + ...ue-deletion-request-output.builder.spec.ts | 46 +++++ .../queue-deletion-request-output.builder.ts | 29 +++ .../src/modules/deletion/services/index.ts | 4 + .../deletion/services/interface/index.ts | 2 + .../queue-deletion-request-input.interface.ts | 5 + ...queue-deletion-request-output.interface.ts | 5 + .../services/references.service.spec.ts | 74 +++++++ .../deletion/services/references.service.ts | 27 +++ .../deletion/uc/batch-deletion.uc.spec.ts | 195 ++++++++++++++++++ .../modules/deletion/uc/batch-deletion.uc.ts | 71 +++++++ ...ch-deletion-summary-detail.builder.spec.ts | 69 +++++++ .../batch-deletion-summary-detail.builder.ts | 8 + .../batch-deletion-summary.builder.spec.ts | 30 +++ .../builder/batch-deletion-summary.builder.ts | 13 ++ .../src/modules/deletion/uc/builder/index.ts | 2 + apps/server/src/modules/deletion/uc/index.ts | 2 + ...batch-deletion-summary-detail.interface.ts | 6 + ...ch-deletion-summary-overall-status.enum.ts | 4 + .../batch-deletion-summary.interface.ts | 9 + .../modules/deletion/uc/interface/index.ts | 3 + config/default.schema.json | 17 ++ nest-cli.json | 9 + package.json | 3 + 56 files changed, 1484 insertions(+) create mode 100644 apps/server/src/apps/deletion-console.app.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/index.ts create mode 100644 apps/server/src/modules/deletion/client/deletion-client.config.spec.ts create mode 100644 apps/server/src/modules/deletion/client/deletion-client.config.ts create mode 100644 apps/server/src/modules/deletion/client/deletion.client.spec.ts create mode 100644 apps/server/src/modules/deletion/client/deletion.client.ts create mode 100644 apps/server/src/modules/deletion/client/index.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/index.ts create mode 100644 apps/server/src/modules/deletion/console/builder/index.ts create mode 100644 apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-console.module.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-queue.console.ts create mode 100644 apps/server/src/modules/deletion/console/index.ts create mode 100644 apps/server/src/modules/deletion/console/interface/index.ts create mode 100644 apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts create mode 100644 apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/batch-deletion.service.ts create mode 100644 apps/server/src/modules/deletion/services/builder/index.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts create mode 100644 apps/server/src/modules/deletion/services/interface/index.ts create mode 100644 apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts create mode 100644 apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts create mode 100644 apps/server/src/modules/deletion/services/references.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/references.service.ts create mode 100644 apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/batch-deletion.uc.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/index.ts create mode 100644 apps/server/src/modules/deletion/uc/index.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts diff --git a/apps/server/src/apps/deletion-console.app.ts b/apps/server/src/apps/deletion-console.app.ts new file mode 100644 index 00000000000..cafb137e160 --- /dev/null +++ b/apps/server/src/apps/deletion-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { BootstrapConsole } from 'nestjs-console'; +import { DeletionConsoleModule } from '@modules/deletion'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: DeletionConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exitCode = 1; + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..bd49bb841e6 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestInputBuilder } from './deletion-request-input.builder'; + +describe(DeletionRequestInputBuilder.name, () => { + describe(DeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'school'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 43200; + + const expectedOutput: DeletionRequestInput = { + targetRef: { + domain: targetRefDomain, + id: targetRefId, + }, + deleteInMinutes, + }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = DeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts new file mode 100644 index 00000000000..28091418065 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts @@ -0,0 +1,11 @@ +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +export class DeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes?: number): DeletionRequestInput { + return { + targetRef: DeletionRequestTargetRefInputBuilder.build(targetRefDomain, targetRefId), + deleteInMinutes, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..399821f33ff --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestOutput } from '../interface'; +import { DeletionRequestOutputBuilder } from './deletion-request-output.builder'; + +describe(DeletionRequestOutputBuilder.name, () => { + describe(DeletionRequestOutputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: DeletionRequestOutput = { + requestId, + deletionPlannedAt, + }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = DeletionRequestOutputBuilder.build(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts new file mode 100644 index 00000000000..9192c1a47ce --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts @@ -0,0 +1,10 @@ +import { DeletionRequestOutput } from '../interface'; + +export class DeletionRequestOutputBuilder { + static build(requestId: string, deletionPlannedAt: Date): DeletionRequestOutput { + return { + requestId, + deletionPlannedAt, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts new file mode 100644 index 00000000000..74b0631e49d --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestTargetRefInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +describe(DeletionRequestTargetRefInputBuilder.name, () => { + describe(DeletionRequestTargetRefInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const domain = 'user'; + const id = new ObjectId().toHexString(); + + const expectedOutput: DeletionRequestTargetRefInput = { domain, id }; + + return { domain, id, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { domain, id, expectedOutput } = setup(); + + const output = DeletionRequestTargetRefInputBuilder.build(domain, id); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts new file mode 100644 index 00000000000..ed3c0219993 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts @@ -0,0 +1,7 @@ +import { DeletionRequestTargetRefInput } from '../interface'; + +export class DeletionRequestTargetRefInputBuilder { + static build(domain: string, id: string): DeletionRequestTargetRefInput { + return { domain, id }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/index.ts b/apps/server/src/modules/deletion/client/builder/index.ts new file mode 100644 index 00000000000..85644a6b2ee --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/index.ts @@ -0,0 +1,3 @@ +export * from './deletion-request-target-ref-input.builder'; +export * from './deletion-request-input.builder'; +export * from './deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts new file mode 100644 index 00000000000..a3cae21e425 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts @@ -0,0 +1,41 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; +import { getDeletionClientConfig } from './deletion-client.config'; + +describe(getDeletionClientConfig.name, () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + afterEach(() => { + Configuration.reset(configBefore); + }); + + describe('when called', () => { + const setup = () => { + const baseUrl = 'http://api-admin:4030'; + const apiKey = '652559c2-93da-42ad-94e1-640e3afbaca0'; + + Configuration.set('ADMIN_API_CLIENT__BASE_URL', baseUrl); + Configuration.set('ADMIN_API_CLIENT__API_KEY', apiKey); + + const expectedConfig: DeletionClientConfig = { + ADMIN_API_CLIENT_BASE_URL: baseUrl, + ADMIN_API_CLIENT_API_KEY: apiKey, + }; + + return { expectedConfig }; + }; + + it('should return config with proper values', () => { + const { expectedConfig } = setup(); + + const config = getDeletionClientConfig(); + + expect(config).toEqual(expectedConfig); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.ts b/apps/server/src/modules/deletion/client/deletion-client.config.ts new file mode 100644 index 00000000000..db5bf7ff226 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.ts @@ -0,0 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; + +export const getDeletionClientConfig = (): DeletionClientConfig => { + return { + ADMIN_API_CLIENT_BASE_URL: Configuration.get('ADMIN_API_CLIENT__BASE_URL') as string, + ADMIN_API_CLIENT_API_KEY: Configuration.get('ADMIN_API_CLIENT__API_KEY') as string, + }; +}; diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts new file mode 100644 index 00000000000..096b1f9b082 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { axiosResponseFactory } from '@shared/testing'; +import { DeletionRequestInputBuilder, DeletionRequestOutputBuilder } from '.'; +import { DeletionRequestOutput } from './interface'; +import { DeletionClient } from './deletion.client'; + +describe(DeletionClient.name, () => { + let module: TestingModule; + let client: DeletionClient; + let httpService: DeepMocked; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionClient, + { + provide: ConfigService, + useValue: createMock({ + get: jest.fn((key: string) => { + if (key === 'ADMIN_API_CLIENT_BASE_URL') { + return 'http://localhost:4030'; + } + + // Default is for the Admin APIs API Key. + return '6b3df003-61e9-467c-9e6b-579634801896'; + }), + }), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + client = module.get(DeletionClient); + httpService = module.get(HttpService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('queueDeletionRequest', () => { + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '6536ce29b595d7c8e5faf200', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input, output }; + }; + + it('should return proper output', async () => { + const { input, output } = setup(); + + const result = await client.queueDeletionRequest(input); + + expect(result).toEqual(output); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build('', new Date()); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no requestId in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no deletionPlannedAt in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: { + requestId: '6536ce29b595d7c8e5faf200', + }, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts new file mode 100644 index 00000000000..66bb267d070 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -0,0 +1,66 @@ +import { firstValueFrom } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; + +@Injectable() +export class DeletionClient { + private readonly baseUrl: string; + + private readonly apiKey: string; + + private readonly postDeletionRequestsEndpoint: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { + this.baseUrl = this.configService.get('ADMIN_API_CLIENT_BASE_URL'); + this.apiKey = this.configService.get('ADMIN_API_CLIENT_API_KEY'); + + // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. + this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + } + + async queueDeletionRequest(input: DeletionRequestInput): Promise { + const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); + + return firstValueFrom(request) + .then((resp: AxiosResponse) => { + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw new Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + }) + .catch((err: Error) => { + // Throw an error if sending/processing deletion request by the client failed in any way. + throw new Error(`failed to send/process a deletion request: ${err.toString()}`); + }); + } + + private apiKeyHeader() { + return { 'X-Api-Key': this.apiKey }; + } + + private defaultHeaders() { + return { + headers: this.apiKeyHeader(), + }; + } +} diff --git a/apps/server/src/modules/deletion/client/index.ts b/apps/server/src/modules/deletion/client/index.ts new file mode 100644 index 00000000000..fde3db98f3b --- /dev/null +++ b/apps/server/src/modules/deletion/client/index.ts @@ -0,0 +1,3 @@ +export * from './interface'; +export * from './builder'; +export * from './deletion.client'; diff --git a/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts new file mode 100644 index 00000000000..8178515b6d4 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionClientConfig { + ADMIN_API_CLIENT_BASE_URL: string; + ADMIN_API_CLIENT_API_KEY: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts new file mode 100644 index 00000000000..4879ce4d972 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts @@ -0,0 +1,6 @@ +import { DeletionRequestTargetRefInput } from './deletion-request-target-ref-input.interface'; + +export interface DeletionRequestInput { + targetRef: DeletionRequestTargetRefInput; + deleteInMinutes?: number; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts new file mode 100644 index 00000000000..b61a372d5a5 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestOutput { + requestId: string; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts new file mode 100644 index 00000000000..603bb0c13ec --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestTargetRefInput { + domain: string; + id: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/index.ts b/apps/server/src/modules/deletion/client/interface/index.ts new file mode 100644 index 00000000000..38f0f639731 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/index.ts @@ -0,0 +1,4 @@ +export * from './deletion-client-config.interface'; +export * from './deletion-request-target-ref-input.interface'; +export * from './deletion-request-input.interface'; +export * from './deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts new file mode 100644 index 00000000000..12fd0997ebe --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts new file mode 100644 index 00000000000..5c83defdd1e --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts @@ -0,0 +1,43 @@ +import { PushDeletionRequestsOptions } from '../interface'; +import { PushDeleteRequestsOptionsBuilder } from './push-delete-requests-options.builder'; + +describe(PushDeleteRequestsOptionsBuilder.name, () => { + describe(PushDeleteRequestsOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const expectedOutput: PushDeletionRequestsOptions = { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + expectedOutput, + }; + }; + + it('should return valid object with expected values', () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, expectedOutput } = setup(); + + const output = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts new file mode 100644 index 00000000000..f8ceae9263d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts @@ -0,0 +1,17 @@ +import { PushDeletionRequestsOptions } from '../interface'; + +export class PushDeleteRequestsOptionsBuilder { + static build( + refsFilePath: string, + targetRefDomain: string, + deleteInMinutes: number, + callsDelayMs: number + ): PushDeletionRequestsOptions { + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts new file mode 100644 index 00000000000..0585b3631da --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { ConsoleModule } from 'nestjs-console'; +import { ConsoleWriterModule } from '@infra/console'; +import { createConfigModuleOptions } from '@src/config'; +import { DeletionClient } from '../client'; +import { getDeletionClientConfig } from '../client/deletion-client.config'; +import { BatchDeletionService } from '../services'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; + +@Module({ + imports: [ + ConsoleModule, + ConsoleWriterModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), + ], + providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], +}) +export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts new file mode 100644 index 00000000000..61f9cf0ff53 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@infra/console'; +import { createMock } from '@golevelup/ts-jest'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; +import { PushDeleteRequestsOptionsBuilder } from './builder'; + +describe(DeletionQueueConsole.name, () => { + let module: TestingModule; + let console: DeletionQueueConsole; + let batchDeletionUc: BatchDeletionUc; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionQueueConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: BatchDeletionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionQueueConsole); + batchDeletionUc = module.get(BatchDeletionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('pushDeletionRequests', () => { + describe('when called with valid options', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const options = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + options, + }; + }; + + it(`should call ${BatchDeletionUc.name} with proper arguments`, async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, options } = setup(); + + const spy = jest.spyOn(batchDeletionUc, 'deleteRefsFromTxtFile'); + + await console.pushDeletionRequests(options); + + expect(spy).toBeCalledWith(refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.ts new file mode 100644 index 00000000000..c8b133dba84 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.ts @@ -0,0 +1,46 @@ +import { Console, Command } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { BatchDeletionUc } from '../uc'; +import { PushDeletionRequestsOptions } from './interface'; + +@Console({ command: 'queue', description: 'Console providing an access to the deletion queue.' }) +export class DeletionQueueConsole { + constructor(private consoleWriter: ConsoleWriterService, private batchDeletionUc: BatchDeletionUc) {} + + @Command({ + command: 'push', + description: 'Push new deletion requests to the deletion queue.', + options: [ + { + flags: '-rfp, --refsFilePath ', + description: 'Path of the file containing all the references to the data that should be deleted.', + required: true, + }, + { + flags: '-trd, --targetRefDomain ', + description: 'Name of the target ref domain.', + required: false, + }, + { + flags: '-dim, --deleteInMinutes ', + description: 'Number of minutes after which the data deletion process should begin.', + required: false, + }, + { + flags: '-cdm, --callsDelayMs ', + description: 'Delay between all the performed client calls, in milliseconds.', + required: false, + }, + ], + }) + async pushDeletionRequests(options: PushDeletionRequestsOptions): Promise { + const summary = await this.batchDeletionUc.deleteRefsFromTxtFile( + options.refsFilePath, + options.targetRefDomain, + options.deleteInMinutes ? Number(options.deleteInMinutes) : undefined, + options.callsDelayMs ? Number(options.callsDelayMs) : undefined + ); + + this.consoleWriter.info(JSON.stringify(summary)); + } +} diff --git a/apps/server/src/modules/deletion/console/index.ts b/apps/server/src/modules/deletion/console/index.ts new file mode 100644 index 00000000000..db47bd8c99f --- /dev/null +++ b/apps/server/src/modules/deletion/console/index.ts @@ -0,0 +1 @@ +export * from './deletion-console.module'; diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts new file mode 100644 index 00000000000..2fcb281430f --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.interface'; diff --git a/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts new file mode 100644 index 00000000000..a54f652cf94 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts @@ -0,0 +1,6 @@ +export interface PushDeletionRequestsOptions { + refsFilePath: string; + targetRefDomain: string; + deleteInMinutes: number; + callsDelayMs: number; +} diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index bd89c1e8d84..793f306e7a0 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,2 +1,4 @@ export * from './deletion.module'; export * from './services'; +export * from './client'; +export * from './console'; diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts new file mode 100644 index 00000000000..640e290af1a --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts @@ -0,0 +1,97 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient, DeletionRequestOutput, DeletionRequestOutputBuilder } from '../client'; +import { QueueDeletionRequestInputBuilder, QueueDeletionRequestOutputBuilder } from './builder'; +import { BatchDeletionService } from './batch-deletion.service'; + +describe(BatchDeletionService.name, () => { + let module: TestingModule; + let service: BatchDeletionService; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionService, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(BatchDeletionService); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('queueDeletionRequests', () => { + describe('when called with valid inputs array and a requested delay between the client calls', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const queueDeletionRequestOutput: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + requestId, + deletionPlannedAt + ); + + deletionClient.queueDeletionRequest.mockResolvedValueOnce(queueDeletionRequestOutput); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with successful status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs, 1); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const error = new Error('connection error'); + + deletionClient.queueDeletionRequest.mockRejectedValueOnce(error); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildError(error); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with failure status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.ts new file mode 100644 index 00000000000..d8c14f315a0 --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { QueueDeletionRequestOutputBuilder } from './builder'; +import { DeletionClient, DeletionRequestInputBuilder } from '../client'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from './interface'; + +@Injectable() +export class BatchDeletionService { + constructor(private readonly deletionClient: DeletionClient) {} + + async queueDeletionRequests( + inputs: QueueDeletionRequestInput[], + callsDelayMilliseconds?: number + ): Promise { + const outputs: QueueDeletionRequestOutput[] = []; + + // For every provided deletion request input, try to queue it via deletion client. + // In any case, add the result of the trial to the outputs - it will be either a valid + // response in a form of a requestId + deletionPlannedAt values pair or some error + // returned from the client. In any case, every input should be processed. + for (const input of inputs) { + const deletionRequestInput = DeletionRequestInputBuilder.build( + input.targetRefDomain, + input.targetRefId, + input.deleteInMinutes + ); + + try { + // eslint-disable-next-line no-await-in-loop + const deletionRequestOutput = await this.deletionClient.queueDeletionRequest(deletionRequestInput); + + // In case of a successful client response, add the + // requestId + deletionPlannedAt values pair to the outputs. + outputs.push( + QueueDeletionRequestOutputBuilder.buildSuccess( + deletionRequestOutput.requestId, + deletionRequestOutput.deletionPlannedAt + ) + ); + } catch (err) { + // In case of a failure client response, add the full error message to the outputs. + outputs.push(QueueDeletionRequestOutputBuilder.buildError(err as Error)); + } + + // If any delay between the client calls has been requested, "sleep" for the specified amount of time. + if (callsDelayMilliseconds && callsDelayMilliseconds > 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, callsDelayMilliseconds); + }); + } + } + + return outputs; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/index.ts b/apps/server/src/modules/deletion/services/builder/index.ts new file mode 100644 index 00000000000..acd85a37989 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.builder'; +export * from './queue-deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..e5d87858156 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestInput } from '../interface'; +import { QueueDeletionRequestInputBuilder } from './queue-deletion-request-input.builder'; + +describe(QueueDeletionRequestInputBuilder.name, () => { + describe(QueueDeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'user'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 60; + + const expectedOutput: QueueDeletionRequestInput = { targetRefDomain, targetRefId, deleteInMinutes }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts new file mode 100644 index 00000000000..a7fff2152b9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts @@ -0,0 +1,7 @@ +import { QueueDeletionRequestInput } from '../interface'; + +export class QueueDeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes: number): QueueDeletionRequestInput { + return { targetRefDomain, targetRefId, deleteInMinutes }; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..cd835a9cf4a --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts @@ -0,0 +1,46 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestOutput } from '../interface'; +import { QueueDeletionRequestOutputBuilder } from './queue-deletion-request-output.builder'; + +describe(QueueDeletionRequestOutputBuilder.name, () => { + describe(QueueDeletionRequestOutputBuilder.buildSuccess.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: QueueDeletionRequestOutput = { requestId, deletionPlannedAt }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(QueueDeletionRequestOutputBuilder.buildError.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: QueueDeletionRequestOutput = { error: error.toString() }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildError(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts new file mode 100644 index 00000000000..c44e503cbaf --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts @@ -0,0 +1,29 @@ +import { QueueDeletionRequestOutput } from '../interface'; + +export class QueueDeletionRequestOutputBuilder { + private static build(requestId?: string, deletionPlannedAt?: Date, error?: string): QueueDeletionRequestOutput { + const output: QueueDeletionRequestOutput = {}; + + if (requestId) { + output.requestId = requestId; + } + + if (deletionPlannedAt) { + output.deletionPlannedAt = deletionPlannedAt; + } + + if (error) { + output.error = error.toString(); + } + + return output; + } + + static buildSuccess(requestId: string, deletionPlannedAt: Date): QueueDeletionRequestOutput { + return this.build(requestId, deletionPlannedAt); + } + + static buildError(err: Error): QueueDeletionRequestOutput { + return this.build(undefined, undefined, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts index 9661354718c..52e0b6ba22d 100644 --- a/apps/server/src/modules/deletion/services/index.ts +++ b/apps/server/src/modules/deletion/services/index.ts @@ -1 +1,5 @@ +export * from './interface'; +export * from './builder'; +export * from './references.service'; +export * from './batch-deletion.service'; export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/services/interface/index.ts b/apps/server/src/modules/deletion/services/interface/index.ts new file mode 100644 index 00000000000..8a455440798 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.interface'; +export * from './queue-deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts new file mode 100644 index 00000000000..b421943bce9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestInput { + targetRefDomain: string; + targetRefId: string; + deleteInMinutes: number; +} diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts new file mode 100644 index 00000000000..375ff811857 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestOutput { + requestId?: string; + deletionPlannedAt?: Date; + error?: string; +} diff --git a/apps/server/src/modules/deletion/services/references.service.spec.ts b/apps/server/src/modules/deletion/services/references.service.spec.ts new file mode 100644 index 00000000000..26eb6e0c8d7 --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.spec.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import { ReferencesService } from './references.service'; + +describe(ReferencesService.name, () => { + describe(ReferencesService.loadFromTxtFile.name, () => { + const setup = (mockedFileContent: string) => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockedFileContent); + }; + + describe('when passed a completely empty file (without any content)', () => { + it('should return an empty references array', () => { + setup(''); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file without any references (just some empty lines)', () => { + it('should return an empty references array', () => { + setup('\n\n \n \n\n\n \n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file with 3 references on a few separate lines', () => { + describe('split with CRs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r653fd3b784ca851b17e9857a\r653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with LFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\n653fd3b784ca851b17e9857a\n653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with CRLFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r\n653fd3b784ca851b17e9857a\r\n653fd3b784ca851b17e9857b\r\n\r\n\r\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/references.service.ts b/apps/server/src/modules/deletion/services/references.service.ts new file mode 100644 index 00000000000..991c36c02af --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; + +export class ReferencesService { + static loadFromTxtFile(filePath: string): string[] { + let fileContent = fs.readFileSync(filePath).toString(); + + // Replace all the CRLF occurrences with just a LF. + fileContent = fileContent.replace(/\r\n?/g, '\n'); + + // Split the whole file content by a line feed (LF) char (\n). + const fileLines = fileContent.split('\n'); + + const references: string[] = []; + + // Iterate over all the file lines and if it contains a valid id (which is + // basically any non-empty string), add it to the loaded references array. + fileLines.forEach((fileLine) => { + const reference = fileLine.trim(); + + if (reference && reference.length > 0) { + references.push(reference); + } + }); + + return references; + } +} diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts new file mode 100644 index 00000000000..7292f36efb0 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts @@ -0,0 +1,195 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + BatchDeletionService, + QueueDeletionRequestInputBuilder, + QueueDeletionRequestOutput, + QueueDeletionRequestOutputBuilder, + ReferencesService, +} from '../services'; +import { BatchDeletionSummaryDetail, BatchDeletionSummaryOverallStatus } from './interface'; +import { BatchDeletionSummaryDetailBuilder } from './builder'; +import { BatchDeletionUc } from './batch-deletion.uc'; + +describe(BatchDeletionUc.name, () => { + let module: TestingModule; + let uc: BatchDeletionUc; + let batchDeletionService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionUc, + { + provide: BatchDeletionService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(BatchDeletionUc); + batchDeletionService = module.get(BatchDeletionService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('deleteRefsFromTxtFile', () => { + describe('when called with valid arguments', () => { + describe('when batch deletion service returns an expected amount of outputs', () => { + describe('when only successful executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.SUCCESS, + successCount: 3, + failureCount: 0, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and a successful overall status', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + + describe('when both successful and failed executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs = [ + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + QueueDeletionRequestOutputBuilder.buildError(new Error('some error occurred...')), + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + ]; + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 2, + failureCount: 1, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and failures', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + }); + + describe('when batch deletion service returns an invalid amount of outputs', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount - 1; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce(outputs); + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath }; + }; + + it('should throw an error', async () => { + const { refsFilePath } = setup(); + + const func = () => uc.deleteRefsFromTxtFile(refsFilePath); + + await expect(func()).rejects.toThrow(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts new file mode 100644 index 00000000000..258b1b53f65 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { BatchDeletionSummaryBuilder, BatchDeletionSummaryDetailBuilder } from './builder'; +import { + ReferencesService, + BatchDeletionService, + QueueDeletionRequestInput, + QueueDeletionRequestInputBuilder, +} from '../services'; +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from './interface'; + +@Injectable() +export class BatchDeletionUc { + constructor(private readonly batchDeletionService: BatchDeletionService) {} + + async deleteRefsFromTxtFile( + refsFilePath: string, + targetRefDomain = 'user', + deleteInMinutes = 43200, // 43200 minutes = 720 hours = 30 days + callsDelayMilliseconds?: number + ): Promise { + // First, load all the references from the provided text file (with given path). + const refsFromTxtFile = ReferencesService.loadFromTxtFile(refsFilePath); + + const inputs: QueueDeletionRequestInput[] = []; + + // For each reference found in a given file, add it to the inputs + // array (with added targetRefDomain and deleteInMinutes fields). + refsFromTxtFile.forEach((ref) => + inputs.push(QueueDeletionRequestInputBuilder.build(targetRefDomain, ref, deleteInMinutes)) + ); + + // Measure the overall queueing execution time by setting the start... + const startTime = performance.now(); + + const outputs = await this.batchDeletionService.queueDeletionRequests(inputs, callsDelayMilliseconds); + + // ...and end timestamps before and after the batch deletion service method execution. + const endTime = performance.now(); + + // Throw an error if the returned outputs number doesn't match the returned inputs number. + if (outputs.length !== inputs.length) { + throw new Error( + 'invalid result from the batch deletion service - expected to ' + + 'receive the same amount of outputs as the provided inputs, ' + + `instead received ${outputs.length} outputs for ${inputs.length} inputs` + ); + } + + const summary: BatchDeletionSummary = BatchDeletionSummaryBuilder.build(endTime - startTime); + + // Go through every received output and, in case of an error presence increase + // a failure count or, in case of no error, increase a success count. + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].error) { + summary.failureCount += 1; + } else { + summary.successCount += 1; + } + + // Also add all the processed inputs and outputs details to the overall summary. + summary.details.push(BatchDeletionSummaryDetailBuilder.build(inputs[i], outputs[i])); + } + + // If no failure has been spotted, assume an overall success. + if (summary.failureCount === 0) { + summary.overallStatus = BatchDeletionSummaryOverallStatus.SUCCESS; + } + + return summary; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts new file mode 100644 index 00000000000..43ea82e86d5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BatchDeletionSummaryDetail } from '..'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetailBuilder } from './batch-deletion-summary-detail.builder'; + +describe(BatchDeletionSummaryDetailBuilder.name, () => { + describe(BatchDeletionSummaryDetailBuilder.build.name, () => { + describe('when called with proper arguments for', () => { + describe('a successful output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + requestId: new ObjectId().toHexString(), + deletionPlannedAt: new Date(), + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + + describe('an error output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + error: 'some error occurred...', + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts new file mode 100644 index 00000000000..9ebbce66171 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts @@ -0,0 +1,8 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetail } from '../interface'; + +export class BatchDeletionSummaryDetailBuilder { + static build(input: QueueDeletionRequestInput, output: QueueDeletionRequestOutput): BatchDeletionSummaryDetail { + return { input, output }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts new file mode 100644 index 00000000000..a2b534602d4 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts @@ -0,0 +1,30 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; +import { BatchDeletionSummaryBuilder } from './batch-deletion-summary.builder'; + +describe(BatchDeletionSummaryBuilder.name, () => { + describe(BatchDeletionSummaryBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const executionTimeMilliseconds = 142; + + const expectedOutput: BatchDeletionSummary = { + executionTimeMilliseconds: 142, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + + return { executionTimeMilliseconds, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { executionTimeMilliseconds, expectedOutput } = setup(); + + const output = BatchDeletionSummaryBuilder.build(executionTimeMilliseconds); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts new file mode 100644 index 00000000000..57fa2bcccd9 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts @@ -0,0 +1,13 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; + +export class BatchDeletionSummaryBuilder { + static build(executionTimeMilliseconds: number): BatchDeletionSummary { + return { + executionTimeMilliseconds, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/index.ts b/apps/server/src/modules/deletion/uc/builder/index.ts new file mode 100644 index 00000000000..46733980f94 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/index.ts @@ -0,0 +1,2 @@ +export * from './batch-deletion-summary-detail.builder'; +export * from './batch-deletion-summary.builder'; diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts new file mode 100644 index 00000000000..cf74de969e5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -0,0 +1,2 @@ +export * from './interface'; +export * from './batch-deletion.uc'; diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts new file mode 100644 index 00000000000..4fe99c13fad --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts @@ -0,0 +1,6 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; + +export interface BatchDeletionSummaryDetail { + input: QueueDeletionRequestInput; + output: QueueDeletionRequestOutput; +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts new file mode 100644 index 00000000000..4ae91bdf70e --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts @@ -0,0 +1,4 @@ +export const enum BatchDeletionSummaryOverallStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts new file mode 100644 index 00000000000..ce633e164f1 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts @@ -0,0 +1,9 @@ +import { BatchDeletionSummaryDetail } from './batch-deletion-summary-detail.interface'; + +export interface BatchDeletionSummary { + executionTimeMilliseconds: number; + overallStatus: string; + successCount: number; + failureCount: number; + details: BatchDeletionSummaryDetail[]; +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts index 95786098275..9dcf644f410 100644 --- a/apps/server/src/modules/deletion/uc/interface/index.ts +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -1 +1,4 @@ +export * from './batch-deletion-summary-overall-status.enum'; +export * from './batch-deletion-summary-detail.interface'; +export * from './batch-deletion-summary.interface'; export * from './interfaces'; diff --git a/config/default.schema.json b/config/default.schema.json index b971968a4ae..89d0a328a59 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1343,6 +1343,23 @@ "description": "Number of simultaneously synchronized students, teachers and classes" } } + }, + "ADMIN_API_CLIENT": { + "type": "object", + "description": "Configuration of the schulcloud-server's admin API client.", + "properties": { + "BASE_URL": { + "type": "string", + "description": "Base URL of the Admin API." + }, + "API_KEY": { + "type": "string", + "description": "API key for accessing the Admin API." + } + }, + "default": { + "BASE_URL": "http://localhost:4030" + } } }, "required": [], diff --git a/nest-cli.json b/nest-cli.json index 73dea03c093..8ce5461bb6f 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -45,6 +45,15 @@ "tsConfigPath": "apps/server/tsconfig.app.json" } }, + "deletion-console": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/deletion-console.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } + }, "files-storage": { "type": "application", "root": "apps/server", diff --git a/package.json b/package.json index 53a0752ee10..4153b7636e7 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", + "nest:start:deletion-console": "nest start deletion-console --", + "nest:start:deletion-console:dev": "nest start deletion-console --watch --", + "nest:start:deletion-console:debug": "nest start deletion-console --debug --watch --", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest", "nest:test:unit": "jest \"^((?!\\.api\\.spec\\.ts).)*\\.spec\\.ts$\"", From 7cf730c05447f9e243499c7174ad68ab7afe6ed8 Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:09:00 +0100 Subject: [PATCH 6/6] BC-5405 - Metadata on k8s ressources (#4517) * add specific labels to deployments, cronjobs and pods --- .../templates/amqp-files-deployment.yml.j2 | 14 +++++++++++++ .../api-delete-s3-files-cronjob.yml.j2 | 21 ++++++++++++++++--- .../templates/api-files-deployment.yml.j2 | 14 +++++++++++++ .../templates/api-fwu-deployment.yml.j2 | 14 +++++++++++++ .../templates/deployment.yml.j2 | 14 +++++++++++++ .../preview-generator-deployment.yml.j2 | 14 +++++++++++++ .../preview-generator-scaled-object.yml.j2 | 7 +++++++ .../templates/api-h5p-deployment.yml.j2 | 14 +++++++++++++ .../templates/management-deployment.yml.j2 | 14 +++++++++++++ .../api-ldap-sync-full-cronjob.yml.j2 | 17 +++++++++++++++ .../api-ldap-worker-deployment.yml.j2 | 14 +++++++++++++ .../api-tsp-sync-base-cronjob.yml.j2 | 20 +++++++++++++++--- .../templates/api-tsp-sync-deployment.yml.j2 | 14 +++++++++++++ .../api-tsp-sync-school-cronjob.yml.j2 | 20 +++++++++++++++--- 14 files changed, 202 insertions(+), 9 deletions(-) diff --git a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 index 17384845b71..e3843effc48 100644 --- a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: amqp-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: amqp-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ AMQP_FILE_STORAGE_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: amqp-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: amqp-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 index 317d37dcda9..2eb0fdd4094 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 @@ -5,14 +5,18 @@ metadata: labels: app: api cronjob: delete-s3-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: delete-s3-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-delete-s3-files-cronjob spec: concurrencyPolicy: Forbid schedule: "{{ SERVER_FILE_DELETION_CRONJOB_SCHEDULE|default("@hourly", true) }}" jobTemplate: - labels: - app: api - cronjob: delete-s3-files spec: template: spec: @@ -34,3 +38,14 @@ spec: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} restartPolicy: OnFailure + metadata: + labels: + app: api + cronjob: delete-s3-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: delete-s3-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 index 727b5a9f4f0..7866e23dcc3 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-files-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_FILE_STORAGE_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-files + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 index c8d7e6f894c..5fe3f528bde 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-fwu-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-fwu + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-fwu + app.kubernetes.io/component: fwu + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_FWU_LEARNING_CONTENTS_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-fwu + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-fwu + app.kubernetes.io/component: fwu + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 index 0750bf91495..a18102bb55d 100644 --- a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api + app.kubernetes.io/component: server + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api + app.kubernetes.io/component: server + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 index 51d87b88755..cffa1dc02b8 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ AMQP_FILE_PREVIEW_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 index 2f8f9091b8e..6058b1b0ccb 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 @@ -6,6 +6,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: preview-generator + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: preview-generator + app.kubernetes.io/component: files + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: scaleTargetRef: name: preview-generator-deployment diff --git a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 index 4e41f454ed9..e5923a2e1b9 100644 --- a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-h5p + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-h5p + app.kubernetes.io/component: h5p + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_H5P_EDITOR_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-h5p + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-h5p + app.kubernetes.io/component: h5p + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 b/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 index 9d99c672be8..93378069cc9 100644 --- a/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/management-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: management-deployment + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: management-deployment + app.kubernetes.io/component: management + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_MANAGEMENT_REPLICAS|default("1", true) }} strategy: @@ -20,6 +27,13 @@ spec: metadata: labels: app: management-deployment + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: management-deployment + app.kubernetes.io/component: management + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 index 74cc37d75b6..db852a7081f 100644 --- a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-sync-full-cronjob.yml.j2 @@ -4,6 +4,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-ldapsync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-ldapsync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-ldapsync-full-cronjob spec: schedule: "{{ SERVER_LDAP_SYNC_FULL_CRONJOB|default("0 3 * * 3,6", true) }}" @@ -30,3 +37,13 @@ spec: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} restartPolicy: OnFailure + metadata: + labels: + app: api-ldapsync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-ldapsync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 index 838009883d4..9f03dbee883 100644 --- a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-worker + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-worker + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_WORKER_REPLICAS|default("2", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-worker + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-worker + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 index f5a3d0751f4..e46dac14330 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 @@ -4,15 +4,19 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-tsp-sync-base-cronjob spec: schedule: "{{ SERVER_TSP_SYNC_BASE_CRONJOB|default("9 3 * * *", true) }}" jobTemplate: spec: template: - metadata: - labels: - app: api-tsp-sync-cronjob spec: containers: - name: api-tsp-sync-base-cronjob @@ -23,3 +27,13 @@ spec: command: ['/bin/sh','-c'] args: ['curl -H "X-API-Key: $SYNC_API_KEY" "http://{{ API_TSP_SYNC_SVC|default("api-tsp-sync-svc", true) }}:3030/api/v1/sync?target=tsp-base" | python3 -m json.tool'] restartPolicy: OnFailure + metadata: + labels: + app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 index 54595985dd4..614db8cf1f9 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-deployment.yml.j2 @@ -5,6 +5,13 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: replicas: {{ API_TSP_REPLICAS|default("1", true) }} strategy: @@ -21,6 +28,13 @@ spec: metadata: labels: app: api-tsp-sync + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: securityContext: runAsUser: 1000 diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 index a92bd92560e..e47ae85fab1 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 @@ -4,15 +4,19 @@ metadata: namespace: {{ NAMESPACE }} labels: app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} name: api-tsp-sync-school-cronjob spec: schedule: "{{ SERVER_TSP_SYNC_SCHOOL_CRONJOB|default("39 3 * * *", true) }}" jobTemplate: spec: template: - metadata: - labels: - app: api-tsp-sync-cronjob spec: containers: - name: api-tsp-sync-school-cronjob @@ -23,3 +27,13 @@ spec: command: ['/bin/sh','-c'] args: ['curl -H "X-API-Key: $SYNC_API_KEY" "http://{{ API_TSP_SYNC_SVC|default("api-tsp-sync-svc", true) }}:3030/api/v1/sync?target=tsp-school" | python3 -m json.tool'] restartPolicy: OnFailure + metadata: + labels: + app: api-tsp-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-tsp-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }}