From 1e636e14a1d260ccfb71b6c1cf7c08e79c9f4786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Wed, 24 Jul 2024 14:58:59 +0200 Subject: [PATCH] add endpoint to force a migration --- .../mikro-orm/Migration20240724090901.ts | 37 +++ .../api-test/user-login-migration.api.spec.ts | 98 +++++- .../dto/request/force-migration.params.ts | 18 + .../controller/dto/request/index.ts | 2 + .../{ => request}/oauth2-migration.params.ts | 0 .../user-login-migration.controller.ts | 29 +- .../user-login-migration/loggable/index.ts | 3 + ...n-invalid-admin.loggable-exception.spec.ts | 31 ++ ...ration-invalid-admin.loggable-exception.ts | 19 ++ ...ple-email-users.loggable-exception.spec.ts | 30 ++ ...multiple-email-users.loggable-exception.ts | 22 ++ ...lready-migrated.loggable-exception.spec.ts | 31 ++ ...ool-already-migrated.loggable-exception.ts | 22 ++ ...ser-already-migrated.loggable-exception.ts | 2 +- .../service/school-migration.service.ts | 2 +- .../uc/user-login-migration.uc.spec.ts | 308 +++++++++++++++++- .../uc/user-login-migration.uc.ts | 75 ++++- .../user-login-migration-api.module.ts | 2 + .../domain/interface/permission.enum.ts | 1 + .../src/shared/repo/user/user-do.repo.ts | 2 + .../shared/testing/user-role-permissions.ts | 6 +- backup/setup/migrations.json | 9 + backup/setup/roles.json | 1 + 23 files changed, 734 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20240724090901.ts create mode 100644 apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts rename apps/server/src/modules/user-login-migration/controller/dto/{ => request}/oauth2-migration.params.ts (100%) create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20240724090901.ts b/apps/server/src/migrations/mikro-orm/Migration20240724090901.ts new file mode 100644 index 00000000000..1fea5a9cb07 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240724090901.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240724090901 extends Migration { + async up(): Promise { + const superheroRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'superhero' }, + { + $addToSet: { + permissions: { + $each: ['USER_LOGIN_MIGRATION_FORCE'], + }, + }, + } + ); + + if (superheroRoleUpdate.modifiedCount > 0) { + console.info('Permission USER_LOGIN_MIGRATION_FORCE was added to role superhero.'); + } + } + + async down(): Promise { + const superheroRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'superhero' }, + { + $pull: { + permissions: { + $in: ['USER_LOGIN_MIGRATION_FORCE'], + }, + }, + } + ); + + if (superheroRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Removed permission USER_LOGIN_MIGRATION_FORCE from role superhero.'); + } + } +} 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 cb5be880941..90cdf97b133 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 @@ -27,8 +27,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { UUID } from 'bson'; import { Response } from 'supertest'; -import { UserLoginMigrationResponse } from '../dto'; -import { Oauth2MigrationParams } from '../dto/oauth2-migration.params'; +import { ForceMigrationParams, Oauth2MigrationParams, UserLoginMigrationResponse } from '../dto'; jest.mock('jwks-rsa', () => () => { return { @@ -1405,4 +1404,99 @@ describe('UserLoginMigrationController (API)', () => { }); }); }); + + describe('[GET] /user-login-migrations/force-migration', () => { + describe('when forcing a school to migrate', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem], + }); + + const email = 'admin@test.com'; + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ + email, + school, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + adminAccount, + adminUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + adminUser, + }; + }; + + it('should start the migration for the school and migrate the user and school', async () => { + const { requestBody, loggedInClient, school, sourceSystem, targetSystem, adminUser } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + + const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { school: school.id }); + expect(userLoginMigration.sourceSystem?.id).toEqual(sourceSystem.id); + expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + + expect(await em.findOne(User, adminUser.id)).toEqual( + expect.objectContaining({ + externalId: requestBody.externalUserId, + }) + ); + + expect(await em.findOne(SchoolEntity, school.id)).toEqual( + expect.objectContaining({ + externalId: requestBody.externalSchoolId, + }) + ); + }); + }); + + describe('when authentication of user failed', () => { + const setup = () => { + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = 'fail@test.com'; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + + return { + requestBody, + }; + }; + + it('should throw an UnauthorizedException', async () => { + const { requestBody } = setup(); + + const response: Response = await testApiClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts new file mode 100644 index 00000000000..416bb27ee47 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class ForceMigrationParams { + @IsEmail() + @ApiProperty({ description: 'Email of the administrator' }) + email!: string; + + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Target externalId to link it with an external account' }) + externalUserId!: string; + + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Target externalId to link it with an external school' }) + externalSchoolId!: string; +} diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/index.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/index.ts index f6f234cd7e4..a571ed5137e 100644 --- a/apps/server/src/modules/user-login-migration/controller/dto/request/index.ts +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/index.ts @@ -2,3 +2,5 @@ export { UserIdParams } from './user-id.params'; export { SchoolIdParams } from './school-id.params'; export { UserLoginMigrationSearchParams } from './user-login-migration-search.params'; export { UserLoginMigrationMandatoryParams } from './user-login-migration-mandatory.params'; +export { Oauth2MigrationParams } from './oauth2-migration.params'; +export { ForceMigrationParams } from './force-migration.params'; diff --git a/apps/server/src/modules/user-login-migration/controller/dto/oauth2-migration.params.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/oauth2-migration.params.ts similarity index 100% rename from apps/server/src/modules/user-login-migration/controller/dto/oauth2-migration.params.ts rename to apps/server/src/modules/user-login-migration/controller/dto/request/oauth2-migration.params.ts 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 81019ee19a2..e5b48fdc05e 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 @@ -1,6 +1,7 @@ import { Authenticate, CurrentUser, ICurrentUser, JWT } from '@modules/authentication'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { + ApiCreatedResponse, ApiForbiddenResponse, ApiInternalServerErrorResponse, ApiNoContentResponse, @@ -29,13 +30,14 @@ import { UserLoginMigrationUc, } from '../uc'; import { + ForceMigrationParams, + Oauth2MigrationParams, SchoolIdParams, UserLoginMigrationMandatoryParams, UserLoginMigrationResponse, UserLoginMigrationSearchListResponse, UserLoginMigrationSearchParams, } from './dto'; -import { Oauth2MigrationParams } from './dto/oauth2-migration.params'; @ApiTags('UserLoginMigration') @Controller('user-login-migrations') @@ -219,7 +221,7 @@ export class UserLoginMigrationController { @Post('migrate-to-oauth2') @ApiOkResponse({ description: 'The User has been successfully migrated.', status: 200 }) - @ApiInternalServerErrorResponse({ description: 'The migration of the User was not possible.' }) + @ApiUnprocessableEntityResponse({ description: 'The migration of the User was not possible.' }) async migrateUserLogin( @JWT() jwt: string, @CurrentUser() currentUser: ICurrentUser, @@ -227,4 +229,27 @@ export class UserLoginMigrationController { ): Promise { await this.userLoginMigrationUc.migrate(jwt, currentUser.userId, body.systemId, body.code, body.redirectUri); } + + @Post('force-migration') + @ApiOperation({ summary: 'Force migrate an administrator account and its school' }) + @ApiCreatedResponse({ description: 'The user and their school were successfully migrated' }) + @ApiUnprocessableEntityResponse({ + description: + 'There are multiple users with the email,' + + 'or the user is not an administrator,' + + 'or the school is already migrated,' + + 'or the external user id is already assigned', + }) + @ApiNotFoundResponse({ description: 'There is no user with the email' }) + public async forceMigration( + @CurrentUser() currentUser: ICurrentUser, + @Body() forceMigrationParams: ForceMigrationParams + ): Promise { + await this.userLoginMigrationUc.forceMigration( + currentUser.userId, + forceMigrationParams.email, + forceMigrationParams.externalUserId, + forceMigrationParams.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 0a8aad87844..d67203098a0 100644 --- a/apps/server/src/modules/user-login-migration/loggable/index.ts +++ b/apps/server/src/modules/user-login-migration/loggable/index.ts @@ -14,4 +14,7 @@ export * from './identical-user-login-migration-system.loggable-exception'; export * from './moin-schule-system-not-found.loggable-exception'; export { UserNotMigratedLoggableException } from './user-not-migrated.loggable-exception'; export { UserMigrationRollbackSuccessfulLoggable } from './user-migration-rollback-successful.loggable'; +export { UserLoginMigrationSchoolAlreadyMigratedLoggableException } from './user-login-migration-school-already-migrated.loggable-exception'; +export { UserLoginMigrationInvalidAdminLoggableException } from './user-login-migration-invalid-admin.loggable-exception'; +export { UserLoginMigrationMultipleEmailUsersLoggableException } from './user-login-migration-multiple-email-users.loggable-exception'; export * from './debug'; diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.spec.ts new file mode 100644 index 00000000000..0caa1817a40 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UserLoginMigrationInvalidAdminLoggableException } from './user-login-migration-invalid-admin.loggable-exception'; + +describe(UserLoginMigrationInvalidAdminLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const exception = new UserLoginMigrationInvalidAdminLoggableException(userId); + + 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_INVALID_ADMIN', + message: 'The user is not an administrator', + stack: exception.stack, + data: { + userId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.ts new file mode 100644 index 00000000000..606d6fbba96 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-admin.loggable-exception.ts @@ -0,0 +1,19 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserLoginMigrationInvalidAdminLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly userId: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_LOGIN_MIGRATION_INVALID_ADMIN', + message: 'The user is not an administrator', + stack: this.stack, + data: { + userId: this.userId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.spec.ts new file mode 100644 index 00000000000..bd6390b6d07 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { UserLoginMigrationMultipleEmailUsersLoggableException } from './user-login-migration-multiple-email-users.loggable-exception'; + +describe(UserLoginMigrationMultipleEmailUsersLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const email = 'test@test.de'; + const exception = new UserLoginMigrationMultipleEmailUsersLoggableException(email); + + return { + exception, + email, + }; + }; + + it('should return the correct log message', () => { + const { exception, email } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_LOGIN_MIGRATION_MULTIPLE_EMAIL_USERS', + message: 'There is multiple users with this email', + stack: exception.stack, + data: { + email, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.ts new file mode 100644 index 00000000000..fe48277e13f --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-multiple-email-users.loggable-exception.ts @@ -0,0 +1,22 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserLoginMigrationMultipleEmailUsersLoggableException + extends UnprocessableEntityException + implements Loggable +{ + constructor(private readonly email: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_LOGIN_MIGRATION_MULTIPLE_EMAIL_USERS', + message: 'There is multiple users with this email', + stack: this.stack, + data: { + email: this.email, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.spec.ts new file mode 100644 index 00000000000..8fcf37e7e1b --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UserLoginMigrationSchoolAlreadyMigratedLoggableException } from './user-login-migration-school-already-migrated.loggable-exception'; + +describe(UserLoginMigrationSchoolAlreadyMigratedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + const exception = new UserLoginMigrationSchoolAlreadyMigratedLoggableException(schoolId); + + return { + exception, + schoolId, + }; + }; + + it('should return the correct log message', () => { + const { exception, schoolId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_LOGIN_MIGRATION_SCHOOL_HAS_ALREADY_MIGRATED', + message: 'School with externalId has already migrated', + stack: exception.stack, + data: { + schoolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.ts new file mode 100644 index 00000000000..874ff87d8fd --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-school-already-migrated.loggable-exception.ts @@ -0,0 +1,22 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserLoginMigrationSchoolAlreadyMigratedLoggableException + extends UnprocessableEntityException + implements Loggable +{ + constructor(private readonly schoolId: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_LOGIN_MIGRATION_SCHOOL_HAS_ALREADY_MIGRATED', + message: 'School has already migrated', + stack: this.stack, + data: { + schoolId: this.schoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-user-already-migrated.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-user-already-migrated.loggable-exception.ts index 03dec25939a..a6e3310b7b8 100644 --- a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-user-already-migrated.loggable-exception.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-user-already-migrated.loggable-exception.ts @@ -10,7 +10,7 @@ export class UserLoginMigrationUserAlreadyMigratedLoggableException extends Busi title: 'User has already migrated', defaultMessage: 'User with externalId has already migrated', }, - HttpStatus.INTERNAL_SERVER_ERROR, + HttpStatus.UNPROCESSABLE_ENTITY, { multipleUsersFound: true, } 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 27289296348..8c15c4313aa 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 @@ -87,7 +87,7 @@ export class SchoolMigrationService { } } - private hasSchoolMigrated(sourceExternalId: string | undefined, targetExternalId: string): boolean { + public hasSchoolMigrated(sourceExternalId: string | undefined, targetExternalId: string): boolean { const isExternalIdEquivalent: boolean = sourceExternalId === targetExternalId; return isExternalIdEquivalent; 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 0da0459e6a0..5d47a357e7f 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 @@ -12,22 +12,30 @@ import { ProvisioningSystemDto, } from '@modules/provisioning'; import { SystemEntity } from '@modules/system/entity'; +import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo, Page, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { LegacySchoolDo, Page, RoleReference, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; +import { Permission, RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, setupEntities, systemEntityFactory, + userDoFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException } from '../loggable'; +import { + ExternalSchoolNumberMissingLoggableException, + InvalidUserLoginMigrationLoggableException, + UserLoginMigrationInvalidAdminLoggableException, + UserLoginMigrationMultipleEmailUsersLoggableException, + UserLoginMigrationSchoolAlreadyMigratedLoggableException, +} from '../loggable'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; import { UserLoginMigrationUc } from './user-login-migration.uc'; @@ -42,6 +50,8 @@ describe(UserLoginMigrationUc.name, () => { let userMigrationService: DeepMocked; let authenticationService: DeepMocked; let authorizationService: DeepMocked; + let schoolService: DeepMocked; + let userService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -81,6 +91,10 @@ describe(UserLoginMigrationUc.name, () => { provide: LegacySchoolService, useValue: createMock(), }, + { + provide: UserService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), @@ -97,6 +111,8 @@ describe(UserLoginMigrationUc.name, () => { userMigrationService = module.get(UserMigrationService); authenticationService = module.get(AuthenticationService); authorizationService = module.get(AuthorizationService); + schoolService = module.get(LegacySchoolService); + userService = module.get(UserService); }); afterAll(async () => { @@ -104,7 +120,7 @@ describe(UserLoginMigrationUc.name, () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe('getMigrations', () => { @@ -608,4 +624,288 @@ describe(UserLoginMigrationUc.name, () => { }); }); }); + + describe('forceMigration', () => { + describe('when the user and their school can successfully migrate', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const school = legacySchoolDoFactory.build({ + id: user.school.id, + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.school.id, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + schoolService.getSchoolById.mockResolvedValueOnce(school); + schoolMigrationService.hasSchoolMigrated.mockReturnValueOnce(false); + + return { + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should migrate the school', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [ + Permission.USER_LOGIN_MIGRATION_FORCE, + ]); + }); + + it('should migrate the school', async () => { + const { user, externalUserId, externalSchoolId, userLoginMigration, school } = setup(); + + await uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( + school, + externalSchoolId, + userLoginMigration.targetSystemId + ); + }); + + it('should migrate the user', async () => { + const { user, externalUserId, externalSchoolId, userLoginMigration } = setup(); + + await uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).toHaveBeenCalledWith( + user.id, + externalUserId, + userLoginMigration.targetSystemId + ); + }); + }); + + describe('when there is no user with the email', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + NotFoundLoggableException + ); + }); + }); + + describe('when there are multiple users with the email', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo, userDo]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + UserLoginMigrationMultipleEmailUsersLoggableException + ); + }); + }); + + describe('when there is no user id', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: undefined, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + NotFoundLoggableException + ); + }); + }); + + describe('when the user is not an administrator', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.TEACHER, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + UserLoginMigrationInvalidAdminLoggableException + ); + }); + }); + + describe('when there is already a user login migration active', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.school.id, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + UserLoginMigrationSchoolAlreadyMigratedLoggableException + ); + }); + }); + + describe('when the school is already migrated', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const school = legacySchoolDoFactory.build({ + id: user.school.id, + externalId: externalSchoolId, + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.school.id, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + schoolService.getSchoolById.mockResolvedValueOnce(school); + schoolMigrationService.hasSchoolMigrated.mockReturnValueOnce(true); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + UserLoginMigrationSchoolAlreadyMigratedLoggableException + ); + }); + }); + }); }); 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 7eec1701d4c..817c729a74d 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,18 +1,23 @@ import { AuthenticationService } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { OauthDataDto, ProvisioningService } from '@modules/provisioning'; +import { UserService } from '@modules/user'; import { ForbiddenException, Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo, Page, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { LegacySchoolDo, Page, RoleReference, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; +import { Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException, SchoolMigrationSuccessfulLoggable, + UserLoginMigrationInvalidAdminLoggableException, + UserLoginMigrationMultipleEmailUsersLoggableException, + UserLoginMigrationSchoolAlreadyMigratedLoggableException, UserMigrationStartedLoggable, UserMigrationSuccessfulLoggable, } from '../loggable'; @@ -29,6 +34,8 @@ export class UserLoginMigrationUc { private readonly schoolMigrationService: SchoolMigrationService, private readonly authenticationService: AuthenticationService, private readonly authorizationService: AuthorizationService, + private readonly userService: UserService, + private readonly schoolService: LegacySchoolService, private readonly logger: Logger ) {} @@ -87,7 +94,7 @@ export class UserLoginMigrationUc { const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(targetSystemId, redirectUri, code); - this.logger.debug(new UserMigrationStartedLoggable(currentUserId, userLoginMigration)); + this.logger.info(new UserMigrationStartedLoggable(currentUserId, userLoginMigration)); const data: OauthDataDto = await this.provisioningService.getData( targetSystemId, @@ -113,14 +120,72 @@ export class UserLoginMigrationUc { targetSystemId ); - this.logger.debug(new SchoolMigrationSuccessfulLoggable(schoolToMigrate, userLoginMigration)); + this.logger.info(new SchoolMigrationSuccessfulLoggable(schoolToMigrate, userLoginMigration)); } } await this.userMigrationService.migrateUser(currentUserId, data.externalUser.externalId, targetSystemId); - this.logger.debug(new UserMigrationSuccessfulLoggable(currentUserId, userLoginMigration)); + this.logger.info(new UserMigrationSuccessfulLoggable(currentUserId, userLoginMigration)); await this.authenticationService.removeJwtFromWhitelist(userJwt); } + + async forceMigration( + userId: EntityId, + email: string, + externalUserId: string, + externalSchoolId: string + ): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkAllPermissions(user, [Permission.USER_LOGIN_MIGRATION_FORCE]); + + const schoolAdminUsers: UserDO[] = await this.userService.findByEmail(email); + + if (schoolAdminUsers.length === 0) { + throw new NotFoundLoggableException('User', { email }); + } + if (schoolAdminUsers.length > 1) { + throw new UserLoginMigrationMultipleEmailUsersLoggableException(email); + } + + const schoolAdminUser: UserDO = schoolAdminUsers[0]; + // TODO Use new domain object to always have an id + if (!schoolAdminUser.id) { + throw new NotFoundLoggableException('User', { email }); + } + + const isAdmin = !!schoolAdminUser.roles.find((value: RoleReference) => value.name === RoleName.ADMINISTRATOR); + if (!isAdmin) { + throw new UserLoginMigrationInvalidAdminLoggableException(schoolAdminUser.id); + } + + const activeUserLoginMigration: UserLoginMigrationDO | null = + await this.userLoginMigrationService.findMigrationBySchool(schoolAdminUser.schoolId); + if (activeUserLoginMigration) { + throw new UserLoginMigrationSchoolAlreadyMigratedLoggableException(activeUserLoginMigration.schoolId); + } + + const userLoginMigration: UserLoginMigrationDO = await this.userLoginMigrationService.startMigration( + schoolAdminUser.schoolId + ); + + const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolAdminUser.schoolId); + + const hasSchoolMigrated: boolean = this.schoolMigrationService.hasSchoolMigrated( + school.externalId, + externalSchoolId + ); + if (hasSchoolMigrated) { + throw new UserLoginMigrationSchoolAlreadyMigratedLoggableException(schoolAdminUser.schoolId); + } + + await this.schoolMigrationService.migrateSchool(school, externalSchoolId, userLoginMigration.targetSystemId); + + this.logger.info(new SchoolMigrationSuccessfulLoggable(school, userLoginMigration)); + + await this.userMigrationService.migrateUser(schoolAdminUser.id, externalUserId, userLoginMigration.targetSystemId); + + this.logger.info(new UserMigrationSuccessfulLoggable(schoolAdminUser.id, userLoginMigration)); + } } 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 cf2151e31eb..b2e1478e37f 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration-api.module.ts @@ -3,6 +3,7 @@ import { AuthorizationModule } from '@modules/authorization'; import { LegacySchoolModule } from '@modules/legacy-school'; import { OauthModule } from '@modules/oauth'; import { ProvisioningModule } from '@modules/provisioning'; +import { UserModule } from '@modules/user'; import { ImportUserModule } from '@modules/user-import'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; @@ -29,6 +30,7 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; LoggerModule, LegacySchoolModule, ImportUserModule, + UserModule, ], providers: [ UserLoginMigrationUc, diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index 2159ec1b820..d85ed328e55 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -155,6 +155,7 @@ export enum Permission { USER_CREATE = 'USER_CREATE', USER_LOGIN_MIGRATION_ADMIN = 'USER_LOGIN_MIGRATION_ADMIN', USER_LOGIN_MIGRATION_ROLLBACK = 'USER_LOGIN_MIGRATION_ROLLBACK', + USER_LOGIN_MIGRATION_FORCE = 'USER_LOGIN_MIGRATION_FORCE', USER_MIGRATE = 'USER_MIGRATE', USER_UPDATE = 'USER_UPDATE', YEARS_EDIT = 'YEARS_EDIT', diff --git a/apps/server/src/shared/repo/user/user-do.repo.ts b/apps/server/src/shared/repo/user/user-do.repo.ts index 4dba7fb1dd7..2f894280d0a 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -101,6 +101,8 @@ export class UserDORepo extends BaseDORepo { email: new RegExp(`^${email.replace(/\W/g, '\\$&')}$`, 'i'), }); + await this._em.populate(userEntitys, ['roles']); + const userDos: UserDO[] = userEntitys.map((userEntity: User): UserDO => this.mapEntityToDO(userEntity)); return userDos; diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index ef368925d93..6a3008efaf4 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -145,4 +145,8 @@ export const adminPermissions = [ Permission.USER_LOGIN_MIGRATION_ADMIN, ]; -export const superheroPermissions = [Permission.USER_LOGIN_MIGRATION_ROLLBACK, Permission.INSTANCE_VIEW]; +export const superheroPermissions = [ + Permission.USER_LOGIN_MIGRATION_FORCE, + Permission.USER_LOGIN_MIGRATION_ROLLBACK, + Permission.INSTANCE_VIEW, +]; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index dbba0355eee..7a6b74f4f1b 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -178,5 +178,14 @@ "created_at": { "$date": "2024-06-28T07:07:10.278Z" } + }, + { + "_id": { + "$oid": "66a0c52f1935f91a45b9c261" + }, + "name": "Migration20240724090901", + "created_at": { + "$date": "2024-07-24T09:11:11.359Z" + } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 0dc34c41f22..03922f4ff3b 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -200,6 +200,7 @@ "USER_CHANGE_OWN_NAME", "ACCOUNT_VIEW", "ACCOUNT_DELETE", + "USER_LOGIN_MIGRATION_FORCE", "USER_LOGIN_MIGRATION_ROLLBACK", "INSTANCE_VIEW" ],