From 478c6a5edbb889f3564facd7a8e21016b97d8043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Tue, 30 Apr 2024 16:20:16 +0200 Subject: [PATCH 1/6] impl user rollback --- .../mikro-orm/Migration20240430140106.ts | 39 ++++++++++++++ .../controller/dto/index.ts | 2 +- .../controller/dto/request/index.ts | 4 ++ .../controller/dto/request/user-id.params.ts | 9 ++++ ...ser-login-migration-rollback.controller.ts | 33 ++++++++++++ .../user-login-migration/loggable/index.ts | 2 + ...-migration-rollback-successful.loggable.ts | 21 ++++++++ .../user-not-migrated.loggable-exception.ts | 20 +++++++ .../user-login-migration/service/index.ts | 1 + .../user-login-migration-rollback.service.ts | 54 +++++++++++++++++++ .../modules/user-login-migration/uc/index.ts | 1 + .../uc/user-login-migration-rollback.uc.ts | 20 +++++++ .../user-login-migration-api.module.ts | 5 +- .../user-login-migration.module.ts | 3 ++ .../domain/interface/permission.enum.ts | 1 + backup/setup/migrations.json | 9 ++++ 16 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20240430140106.ts create mode 100644 apps/server/src/modules/user-login-migration/controller/dto/request/index.ts create mode 100644 apps/server/src/modules/user-login-migration/controller/dto/request/user-id.params.ts create mode 100644 apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-migration-rollback-successful.loggable.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.ts create mode 100644 apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.ts create mode 100644 apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20240430140106.ts b/apps/server/src/migrations/mikro-orm/Migration20240430140106.ts new file mode 100644 index 00000000000..cb6fa3978f8 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240430140106.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240430140106 extends Migration { + async up(): Promise { + const superheroRoleUpdate = await this.driver.nativeUpdate( + 'roles', + { name: 'superhero' }, + { + $addToSet: { + permissions: { + $each: ['USER_LOGIN_MIGRATION_ROLLBACK'], + }, + }, + } + ); + + if (superheroRoleUpdate.affectedRows > 0) { + console.info('Permission USER_LOGIN_MIGRATION_ROLLBACK was added to role superhero.'); + } + } + + async down(): Promise { + const superheroRoleUpdate = await this.driver.nativeUpdate( + 'roles', + { name: 'superhero' }, + { + $pull: { + permissions: { + $in: ['USER_LOGIN_MIGRATION_ROLLBACK'], + }, + }, + } + ); + + if (superheroRoleUpdate.affectedRows > 0) { + console.info('Rollback: Removed permission USER_LOGIN_MIGRATION_ROLLBACK from role superhero.'); + } + } +} 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 bcbb7e46f4e..989a1911231 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,3 +1,3 @@ -export * from './request/user-login-migration-search.params'; +export * from './request'; export * from './response/user-login-migration.response'; export * from './response/user-login-migration-search-list.response'; 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 new file mode 100644 index 00000000000..f6f234cd7e4 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/index.ts @@ -0,0 +1,4 @@ +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'; diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/user-id.params.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/user-id.params.ts new file mode 100644 index 00000000000..8fa445eb26a --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/user-id.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { IsMongoId } from 'class-validator'; + +export class UserIdParams { + @ApiProperty() + @IsMongoId() + userId!: EntityId; +} diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts new file mode 100644 index 00000000000..055459a5005 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts @@ -0,0 +1,33 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; +import { + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiTags, + ApiUnauthorizedResponse, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; +import { UserLoginMigrationRollbackUc } from '../uc'; +import { UserIdParams } from './dto'; + +@ApiTags('UserLoginMigration Rollback') +@Controller('user-login-migrations') +@Authenticate('jwt') +export class UserLoginMigrationRollbackController { + constructor(private readonly userLoginMigrationRollbackUc: UserLoginMigrationRollbackUc) {} + + @Post('/users/:userId/rollback-migration') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiForbiddenResponse({ description: 'User is not allowed to access this resource' }) + @ApiUnauthorizedResponse({ description: 'User is not logged in' }) + @ApiNoContentResponse({ description: 'The user has been successfully rolled back' }) + @ApiNotFoundResponse({ description: "The user's School has no migration" }) + @ApiUnprocessableEntityResponse({ description: 'The user has not migrated yet' }) + public async migrateUserLogin( + @CurrentUser() currentUser: ICurrentUser, + @Param() userIdParams: UserIdParams + ): Promise { + await this.userLoginMigrationRollbackUc.rollbackUser(currentUser.userId, userIdParams.userId); + } +} 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 b20d3584ed5..0a8aad87844 100644 --- a/apps/server/src/modules/user-login-migration/loggable/index.ts +++ b/apps/server/src/modules/user-login-migration/loggable/index.ts @@ -12,4 +12,6 @@ export * from './school-migration-database-operation-failed.loggable-exception'; export * from './invalid-user-login-migration.loggable-exception'; 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 * from './debug'; diff --git a/apps/server/src/modules/user-login-migration/loggable/user-migration-rollback-successful.loggable.ts b/apps/server/src/modules/user-login-migration/loggable/user-migration-rollback-successful.loggable.ts new file mode 100644 index 00000000000..783aaec16b9 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-migration-rollback-successful.loggable.ts @@ -0,0 +1,21 @@ +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserMigrationRollbackSuccessfulLoggable implements Loggable { + constructor( + private readonly userId?: EntityId, + private readonly externalId?: EntityId, + private readonly userLoginMigrationId?: EntityId + ) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'The user has been successfully rolled back from the migration.', + data: { + userId: this.userId, + externalId: this.externalId, + userLoginMigrationId: this.userLoginMigrationId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.ts new file mode 100644 index 00000000000..07f0dad6ae1 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.ts @@ -0,0 +1,20 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserNotMigratedLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly userId?: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_NOT_MIGRATED', + message: 'The user has not migrated yet.', + stack: this.stack, + data: { + userId: this.userId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/service/index.ts b/apps/server/src/modules/user-login-migration/service/index.ts index e520a35b15e..7da10fe8cc9 100644 --- a/apps/server/src/modules/user-login-migration/service/index.ts +++ b/apps/server/src/modules/user-login-migration/service/index.ts @@ -3,3 +3,4 @@ export * from './school-migration.service'; export * from './migration-check.service'; export * from './user-login-migration.service'; export * from './user-login-migration-revert.service'; +export { UserLoginMigrationRollbackService } from './user-login-migration-rollback.service'; diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.ts new file mode 100644 index 00000000000..26abcd8aa41 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.ts @@ -0,0 +1,54 @@ +import { Account, AccountService } from '@modules/account'; +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { + UserLoginMigrationNotFoundLoggableException, + UserMigrationRollbackSuccessfulLoggable, + UserNotMigratedLoggableException, +} from '../loggable'; +import { UserLoginMigrationService } from './user-login-migration.service'; + +@Injectable() +export class UserLoginMigrationRollbackService { + constructor( + private readonly userService: UserService, + private readonly accountService: AccountService, + private readonly userLoginMigrationService: UserLoginMigrationService, + private readonly logger: Logger + ) {} + + public async rollbackUser(targetUserId: EntityId): Promise { + const user: UserDO = await this.userService.findById(targetUserId); + const account: Account = await this.accountService.findByUserIdOrFail(targetUserId); + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + user.schoolId + ); + + if (!userLoginMigration) { + throw new UserLoginMigrationNotFoundLoggableException(user.schoolId); + } + + if (!user.lastLoginSystemChange) { + throw new UserNotMigratedLoggableException(user.id); + } + + const { externalId } = user; + + user.externalId = user.previousExternalId; + user.previousExternalId = undefined; + user.lastLoginSystemChange = undefined; + if (userLoginMigration.closedAt) { + user.outdatedSince = userLoginMigration.closedAt; + } + + account.systemId = userLoginMigration.sourceSystemId; + + await this.userService.save(user); + await this.accountService.save(account); + + this.logger.info(new UserMigrationRollbackSuccessfulLoggable(user.id, externalId, userLoginMigration.id)); + } +} diff --git a/apps/server/src/modules/user-login-migration/uc/index.ts b/apps/server/src/modules/user-login-migration/uc/index.ts index 18163b59a9f..da6c995ded5 100644 --- a/apps/server/src/modules/user-login-migration/uc/index.ts +++ b/apps/server/src/modules/user-login-migration/uc/index.ts @@ -4,3 +4,4 @@ export * from './start-user-login-migration.uc'; export * from './toggle-user-login-migration.uc'; export * from './restart-user-login-migration.uc'; export * from './close-user-login-migration.uc'; +export * from './user-login-migration-rollback.uc'; diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.ts new file mode 100644 index 00000000000..6de0777922d --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.ts @@ -0,0 +1,20 @@ +import { AuthorizationService } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { UserLoginMigrationRollbackService } from '../service'; + +@Injectable() +export class UserLoginMigrationRollbackUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly userLoginMigrationRollbackService: UserLoginMigrationRollbackService + ) {} + + public async rollbackUser(currentUserId: EntityId, targetUserId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(currentUserId); + this.authorizationService.checkAllPermissions(user, [Permission.USER_LOGIN_MIGRATION_ROLLBACK]); + + await this.userLoginMigrationRollbackService.rollbackUser(targetUserId); + } +} 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 377689a4f18..21471537a76 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 @@ -5,12 +5,14 @@ import { OauthModule } from '@modules/oauth'; import { ProvisioningModule } from '@modules/provisioning'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { UserLoginMigrationRollbackController } from './controller/user-login-migration-rollback.controller'; import { UserLoginMigrationController } from './controller/user-login-migration.controller'; import { CloseUserLoginMigrationUc, RestartUserLoginMigrationUc, StartUserLoginMigrationUc, ToggleUserLoginMigrationUc, + UserLoginMigrationRollbackUc, UserLoginMigrationUc, } from './uc'; import { UserLoginMigrationModule } from './user-login-migration.module'; @@ -31,7 +33,8 @@ import { UserLoginMigrationModule } from './user-login-migration.module'; RestartUserLoginMigrationUc, ToggleUserLoginMigrationUc, CloseUserLoginMigrationUc, + UserLoginMigrationRollbackUc, ], - controllers: [UserLoginMigrationController], + controllers: [UserLoginMigrationController, UserLoginMigrationRollbackController], }) export class UserLoginMigrationApiModule {} diff --git a/apps/server/src/modules/user-login-migration/user-login-migration.module.ts b/apps/server/src/modules/user-login-migration/user-login-migration.module.ts index 8338b39fcba..ec3b9ca768e 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration.module.ts @@ -9,6 +9,7 @@ import { MigrationCheckService, SchoolMigrationService, UserLoginMigrationRevertService, + UserLoginMigrationRollbackService, UserLoginMigrationService, UserMigrationService, } from './service'; @@ -22,6 +23,7 @@ import { UserLoginMigrationService, UserLoginMigrationRepo, UserLoginMigrationRevertService, + UserLoginMigrationRollbackService, ], exports: [ UserMigrationService, @@ -29,6 +31,7 @@ import { MigrationCheckService, UserLoginMigrationService, UserLoginMigrationRevertService, + UserLoginMigrationRollbackService, ], }) export class UserLoginMigrationModule {} diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index eefb2673c7c..3971723377a 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -156,6 +156,7 @@ export enum Permission { USER_CHANGE_OWN_NAME = 'USER_CHANGE_OWN_NAME', USER_CREATE = 'USER_CREATE', USER_LOGIN_MIGRATION_ADMIN = 'USER_LOGIN_MIGRATION_ADMIN', + USER_LOGIN_MIGRATION_ROLLBACK = 'USER_LOGIN_MIGRATION_ROLLBACK', USER_MIGRATE = 'USER_MIGRATE', USER_UPDATE = 'USER_UPDATE', YEARS_EDIT = 'YEARS_EDIT', diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index c0375d8bb5b..89e625ddf95 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -88,5 +88,14 @@ "created_at": { "$date": "2024-04-22T12:32:50.668Z" } + }, + { + "_id": { + "$oid": "6630fa6f92c2ac9d942c1c1d" + }, + "name": "Migration20240430140106", + "created_at": { + "$date": "2024-04-30T14:04:31.141Z" + } } ] From 3affbfe016610f5a9229cbf4f6d167109fd6e7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Thu, 2 May 2024 14:43:38 +0200 Subject: [PATCH 2/6] add tests --- .../user-login-migration-rollback.api.spec.ts | 114 +++++++ ...r-login-migration-rollback.service.spec.ts | 303 ++++++++++++++++++ .../user-login-migration-rollback.uc.spec.ts | 113 +++++++ .../factory/user-and-account.test.factory.ts | 16 +- .../shared/testing/factory/user.factory.ts | 17 +- .../shared/testing/user-role-permissions.ts | 10 +- 6 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts new file mode 100644 index 00000000000..41435c166d2 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration-rollback.api.spec.ts @@ -0,0 +1,114 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; +import { + cleanupCollections, + schoolEntityFactory, + systemEntityFactory, + TestApiClient, + UserAndAccountTestFactory, + userLoginMigrationFactory, +} from '@shared/testing'; +import { Response } from 'supertest'; + +describe('UserLoginMigrationRollbackController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, '/user-login-migrations'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('[GET] /user-login-migrations', () => { + describe('when a user is rolled back', () => { + const setup = async () => { + const date: Date = new Date(2023, 5, 4); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem], + }); + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: date, + mandatorySince: date, + closedAt: date, + finishedAt: date, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school, externalId: 'externalId' }); + adminUser.previousExternalId = 'previousExternalId'; + adminUser.lastLoginSystemChange = date; + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + userLoginMigration, + adminAccount, + adminUser, + superheroAccount, + superheroUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { + loggedInClient, + userLoginMigration, + migratedUser: adminUser, + migratedAccount: adminAccount, + }; + }; + + it('should return the users migration', async () => { + const { loggedInClient, migratedUser, migratedAccount, userLoginMigration } = await setup(); + + const response: Response = await loggedInClient.post(`/users/${migratedUser.id}/rollback-migration`); + + const revertedUser: User = await em.findOneOrFail(User, migratedUser.id); + const revertedAccount: AccountEntity = await em.findOneOrFail(AccountEntity, migratedAccount.id); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + expect(revertedUser.externalId).toEqual(migratedUser.previousExternalId); + expect(revertedUser.previousExternalId).toBeUndefined(); + expect(revertedUser.lastLoginSystemChange).toBeUndefined(); + expect(revertedUser.outdatedSince).toEqual(userLoginMigration.closedAt); + expect(revertedAccount.systemId?.toHexString()).toEqual(userLoginMigration.sourceSystem?.id); + }); + }); + + describe('when unauthorized', () => { + it('should return Unauthorized', async () => { + const response: Response = await testApiClient.post( + `/users/${new ObjectId().toHexString()}/rollback-migration` + ); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.spec.ts new file mode 100644 index 00000000000..bd429057109 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration-rollback.service.spec.ts @@ -0,0 +1,303 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Account, AccountService } from '@modules/account'; +import { UserService } from '@modules/user'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain/domainobject'; +import { accountDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { + UserLoginMigrationNotFoundLoggableException, + UserMigrationRollbackSuccessfulLoggable, + UserNotMigratedLoggableException, +} from '../loggable'; +import { UserLoginMigrationRollbackService } from './user-login-migration-rollback.service'; +import { UserLoginMigrationService } from './user-login-migration.service'; + +describe(UserLoginMigrationRollbackService.name, () => { + let module: TestingModule; + let service: UserLoginMigrationRollbackService; + + let userService: DeepMocked; + let accountService: DeepMocked; + let userLoginMigrationService: DeepMocked; + let logger: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + UserLoginMigrationRollbackService, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + { + provide: UserLoginMigrationService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(UserLoginMigrationRollbackService); + userService = module.get(UserService); + accountService = module.get(AccountService); + userLoginMigrationService = module.get(UserLoginMigrationService); + logger = module.get(Logger); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('rollbackUser', () => { + describe('when an ldap user is rolled back during a migration', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ + id: userId, + externalId: 'externalId', + previousExternalId: 'previousExternalId', + lastLoginSystemChange: new Date(), + }); + const account = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.schoolId, + sourceSystemId: new ObjectId().toHexString(), + closedAt: undefined, + finishedAt: undefined, + }); + + userService.findById.mockResolvedValueOnce(new UserDO(user)); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(account.getProps())); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + userId, + user, + account, + userLoginMigration, + }; + }; + + it('should roll back and save the user', async () => { + const { userId, user } = setup(); + + await service.rollbackUser(userId); + + expect(userService.save).toHaveBeenCalledWith<[UserDO]>( + new UserDO({ + ...user, + externalId: user.previousExternalId, + previousExternalId: undefined, + lastLoginSystemChange: undefined, + outdatedSince: undefined, + }) + ); + }); + + it('should roll back and save the account', async () => { + const { userId, account, userLoginMigration } = setup(); + + await service.rollbackUser(userId); + + expect(accountService.save).toHaveBeenCalledWith<[Account]>( + new Account({ ...account.getProps(), systemId: userLoginMigration.sourceSystemId }) + ); + }); + + it('should log a success message', async () => { + const { userId, user, userLoginMigration } = setup(); + + await service.rollbackUser(userId); + + expect(logger.info).toHaveBeenCalledWith( + new UserMigrationRollbackSuccessfulLoggable(user.id, user.externalId, userLoginMigration.id) + ); + }); + }); + + describe('when a local user is rolled back during a migration', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ + id: userId, + externalId: 'externalId', + previousExternalId: undefined, + lastLoginSystemChange: new Date(), + }); + const account = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.schoolId, + sourceSystemId: undefined, + closedAt: undefined, + finishedAt: undefined, + }); + + userService.findById.mockResolvedValueOnce(new UserDO(user)); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(account.getProps())); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + userId, + user, + account, + userLoginMigration, + }; + }; + + it('should save the user without an externalId', async () => { + const { userId, user } = setup(); + + await service.rollbackUser(userId); + + expect(userService.save).toHaveBeenCalledWith<[UserDO]>( + new UserDO({ + ...user, + externalId: undefined, + previousExternalId: undefined, + lastLoginSystemChange: undefined, + outdatedSince: undefined, + }) + ); + }); + + it('should save the account without a systemId', async () => { + const { userId, account } = setup(); + + await service.rollbackUser(userId); + + expect(accountService.save).toHaveBeenCalledWith<[Account]>( + new Account({ ...account.getProps(), systemId: undefined }) + ); + }); + }); + + describe('when a user is rolled back after a migration was closed', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ + id: userId, + externalId: 'externalId', + previousExternalId: 'previousExternalId', + lastLoginSystemChange: new Date(), + }); + const account = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.schoolId, + sourceSystemId: new ObjectId().toHexString(), + closedAt: new Date(), + finishedAt: new Date(), + }); + + userService.findById.mockResolvedValueOnce(new UserDO(user)); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(account.getProps())); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + userId, + user, + account, + userLoginMigration, + }; + }; + + it('should mark the user as outdated', async () => { + const { userId, user, userLoginMigration } = setup(); + + await service.rollbackUser(userId); + + expect(userService.save).toHaveBeenCalledWith<[UserDO]>( + new UserDO({ + ...user, + externalId: user.previousExternalId, + previousExternalId: undefined, + lastLoginSystemChange: undefined, + outdatedSince: userLoginMigration.closedAt, + }) + ); + }); + }); + + describe('when a user has not migrated yet', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ + id: userId, + externalId: 'externalId', + lastLoginSystemChange: undefined, + }); + const account = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: user.schoolId, + }); + + userService.findById.mockResolvedValueOnce(new UserDO(user)); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(account.getProps())); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + userId, + user, + account, + userLoginMigration, + }; + }; + + it('should throw UserNotMigratedLoggableException', async () => { + const { userId } = setup(); + + await expect(service.rollbackUser(userId)).rejects.toThrow(UserNotMigratedLoggableException); + }); + }); + + describe("when a user's school is not migrating", () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const user = userDoFactory.build({ + id: userId, + externalId: 'externalId', + lastLoginSystemChange: undefined, + }); + const account = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + + userService.findById.mockResolvedValueOnce(new UserDO(user)); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(account.getProps())); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + + return { + userId, + user, + account, + }; + }; + + it('should throw UserLoginMigrationNotFoundLoggableException', async () => { + const { userId } = setup(); + + await expect(service.rollbackUser(userId)).rejects.toThrow(UserLoginMigrationNotFoundLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts new file mode 100644 index 00000000000..04def59c54b --- /dev/null +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts @@ -0,0 +1,113 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationService } from '@modules/authorization'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { setupEntities, userFactory } from '@shared/testing'; +import { UserLoginMigrationRollbackService } from '../service'; +import { UserLoginMigrationRollbackUc } from './user-login-migration-rollback.uc'; + +describe(UserLoginMigrationRollbackUc.name, () => { + let module: TestingModule; + let uc: UserLoginMigrationRollbackUc; + + let authorizationService: DeepMocked; + let userLoginMigrationRollbackService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + UserLoginMigrationRollbackUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: UserLoginMigrationRollbackService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(UserLoginMigrationRollbackUc); + authorizationService = module.get(AuthorizationService); + userLoginMigrationRollbackService = module.get(UserLoginMigrationRollbackService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('rollbackUser', () => { + describe('when a user is rolled back', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const targetUserId = new ObjectId().toHexString(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + targetUserId, + }; + }; + + it('should check the permission', async () => { + const { user, targetUserId } = setup(); + + await uc.rollbackUser(user.id, targetUserId); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [ + Permission.USER_LOGIN_MIGRATION_ROLLBACK, + ]); + }); + + it('should roll the user back', async () => { + const { user, targetUserId } = setup(); + + await uc.rollbackUser(user.id, targetUserId); + + expect(userLoginMigrationRollbackService.rollbackUser).toHaveBeenCalledWith(targetUserId); + }); + }); + + describe('when the authorization fails', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const targetUserId = new ObjectId().toHexString(); + const error = new Error(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + targetUserId, + error, + }; + }; + + it('should throw an error', async () => { + const { user, targetUserId, error } = setup(); + + await expect(uc.rollbackUser(user.id, targetUserId)).rejects.toThrow(error); + }); + + it('should not roll a user back', async () => { + const { user, targetUserId } = setup(); + + await expect(uc.rollbackUser(user.id, targetUserId)).rejects.toThrow(); + + expect(userLoginMigrationRollbackService.rollbackUser).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts b/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts index 9710025ab04..9dc020df24c 100644 --- a/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts +++ b/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts @@ -1,9 +1,9 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from '@mikro-orm/mongodb'; import _ from 'lodash'; -import { AccountEntity } from '@modules/account/entity/account.entity'; import { accountFactory } from './account.factory'; import { userFactory } from './user.factory'; @@ -72,4 +72,16 @@ export class UserAndAccountTestFactory { return { adminAccount: account, adminUser: user }; } + + public static buildSuperhero( + params: UserAndAccountParams = {}, + additionalPermissions: Permission[] = [] + ): { superheroAccount: AccountEntity; superheroUser: User } { + const user = userFactory + .asSuperhero(additionalPermissions) + .buildWithId(UserAndAccountTestFactory.getUserParams(params)); + const account = UserAndAccountTestFactory.buildAccount(user, params); + + return { superheroAccount: account, superheroUser: user }; + } } diff --git a/apps/server/src/shared/testing/factory/user.factory.ts b/apps/server/src/shared/testing/factory/user.factory.ts index 43e579c8b7f..819530568ac 100644 --- a/apps/server/src/shared/testing/factory/user.factory.ts +++ b/apps/server/src/shared/testing/factory/user.factory.ts @@ -11,7 +11,13 @@ import { import { Permission, RoleName } from '@shared/domain/interface'; import { DeepPartial } from 'fishery'; import _ from 'lodash'; -import { adminPermissions, studentPermissions, teacherPermissions, userPermissions } from '../user-role-permissions'; +import { + adminPermissions, + studentPermissions, + superheroPermissions, + teacherPermissions, + userPermissions, +} from '../user-role-permissions'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; import { schoolEntityFactory } from './school-entity.factory'; @@ -83,6 +89,15 @@ class UserFactory extends BaseFactory { return this.params(params); } + + asSuperhero(additionalPermissions: Permission[] = []): this { + const permissions = _.union(userPermissions, superheroPermissions, additionalPermissions); + const role = roleFactory.buildWithId({ permissions, name: RoleName.SUPERHERO }); + + const params: DeepPartial = { roles: [role] }; + + return this.params(params); + } } export const userFactory = UserFactory.define(User, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index 151b9788b5c..47ab40c5741 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -54,7 +54,7 @@ export const userPermissions = [ Permission.COURSE_VIEW, Permission.LERNSTORE_VIEW, Permission.GROUP_VIEW, -] as Permission[]; +]; export const studentPermissions = [ ...userPermissions, @@ -63,7 +63,7 @@ export const studentPermissions = [ Permission.TEAM_CREATE, Permission.JOIN_MEETING, Permission.TOOL_CREATE_ETHERPAD, -] as Permission[]; +]; const sharedAdminPermissions = [ Permission.ACCOUNT_CREATE, @@ -103,7 +103,7 @@ export const teacherPermissions = [ Permission.TOPIC_EDIT, Permission.START_MEETING, Permission.CONTEXT_TOOL_ADMIN, -] as Permission[]; +]; export const adminPermissions = [ ...userPermissions, @@ -143,4 +143,6 @@ export const adminPermissions = [ Permission.SCHOOL_TOOL_ADMIN, Permission.GROUP_FULL_ADMIN, Permission.USER_LOGIN_MIGRATION_ADMIN, -] as Permission[]; +]; + +export const superheroPermissions = [Permission.USER_LOGIN_MIGRATION_ROLLBACK]; From bc9aa85aa9780fc8355327e1b28b2b880ef553a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Thu, 2 May 2024 16:01:44 +0200 Subject: [PATCH 3/6] add test for loggable --- ...er-not-migrated.loggable-exception.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.spec.ts diff --git a/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.spec.ts new file mode 100644 index 00000000000..61964026b5e --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-not-migrated.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UserNotMigratedLoggableException } from './user-not-migrated.loggable-exception'; + +describe(UserNotMigratedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + const exception = new UserNotMigratedLoggableException(userId); + + return { + exception, + userId, + }; + }; + + it('should return the correct log message', () => { + const { exception, userId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_NOT_MIGRATED', + message: 'The user has not migrated yet.', + stack: expect.any(String), + data: { + userId, + }, + }); + }); + }); +}); From 7faa11cf5a8d7edad0194a957cd76e55131525bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Thu, 2 May 2024 16:37:49 +0200 Subject: [PATCH 4/6] review changes --- .../controller/user-login-migration-rollback.controller.ts | 2 +- .../controller/user-login-migration.controller.ts | 4 ++-- .../uc/user-login-migration-rollback.uc.spec.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts index 055459a5005..9445e72cdc9 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts @@ -22,7 +22,7 @@ export class UserLoginMigrationRollbackController { @ApiForbiddenResponse({ description: 'User is not allowed to access this resource' }) @ApiUnauthorizedResponse({ description: 'User is not logged in' }) @ApiNoContentResponse({ description: 'The user has been successfully rolled back' }) - @ApiNotFoundResponse({ description: "The user's School has no migration" }) + @ApiNotFoundResponse({ description: "The user's school has no migration" }) @ApiUnprocessableEntityResponse({ description: 'The user has not migrated yet' }) public async migrateUserLogin( @CurrentUser() currentUser: ICurrentUser, 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 e2fcb0ed44c..4ac3dc6f6dd 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 @@ -28,13 +28,13 @@ import { UserLoginMigrationUc, } from '../uc'; import { + SchoolIdParams, + UserLoginMigrationMandatoryParams, UserLoginMigrationResponse, UserLoginMigrationSearchListResponse, UserLoginMigrationSearchParams, } from './dto'; import { Oauth2MigrationParams } from './dto/oauth2-migration.params'; -import { SchoolIdParams } from './dto/request/school-id.params'; -import { UserLoginMigrationMandatoryParams } from './dto/request/user-login-migration-mandatory.params'; @ApiTags('UserLoginMigration') @Controller('user-login-migrations') diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts index 04def59c54b..b3497cafdbd 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration-rollback.uc.spec.ts @@ -68,7 +68,7 @@ describe(UserLoginMigrationRollbackUc.name, () => { ]); }); - it('should roll the user back', async () => { + it('should rollback a user', async () => { const { user, targetUserId } = setup(); await uc.rollbackUser(user.id, targetUserId); @@ -101,7 +101,7 @@ describe(UserLoginMigrationRollbackUc.name, () => { await expect(uc.rollbackUser(user.id, targetUserId)).rejects.toThrow(error); }); - it('should not roll a user back', async () => { + it('should not rollback a user', async () => { const { user, targetUserId } = setup(); await expect(uc.rollbackUser(user.id, targetUserId)).rejects.toThrow(); From 18589309574870acc2f62a9b86ce9d536a61d468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Thu, 2 May 2024 16:46:41 +0200 Subject: [PATCH 5/6] add role to seed data --- backup/setup/roles.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 5b191cd2201..9a3db6e128b 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -199,7 +199,8 @@ "GROUP_FULL_ADMIN", "USER_CHANGE_OWN_NAME", "ACCOUNT_VIEW", - "ACCOUNT_DELETE" + "ACCOUNT_DELETE", + "USER_LOGIN_MIGRATION_ROLLBACK" ], "__v": 2 }, From 896c22af78ec58363d8b14b66e5038da5c289077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Thu, 2 May 2024 16:58:17 +0200 Subject: [PATCH 6/6] add api operation --- .../controller/user-login-migration-rollback.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts index 9445e72cdc9..b278384b3ac 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration-rollback.controller.ts @@ -4,6 +4,7 @@ import { ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, + ApiOperation, ApiTags, ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, @@ -19,6 +20,7 @@ export class UserLoginMigrationRollbackController { @Post('/users/:userId/rollback-migration') @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Rollback a user from a user login migration' }) @ApiForbiddenResponse({ description: 'User is not allowed to access this resource' }) @ApiUnauthorizedResponse({ description: 'User is not logged in' }) @ApiNoContentResponse({ description: 'The user has been successfully rolled back' })